chore(release): automatic release v0.1.0
This commit is contained in:
@@ -20,8 +20,6 @@ DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
|
|||||||
# DB_PASSWORD='password'
|
# DB_PASSWORD='password'
|
||||||
# DB_NAME='name-of-database'
|
# DB_NAME='name-of-database'
|
||||||
|
|
||||||
# @see https://next-auth.js.org/configuration/options#nextauth_url
|
|
||||||
AUTH_URL='http://localhost:3000'
|
|
||||||
|
|
||||||
# You can generate the secret via 'openssl rand -base64 32' on Unix
|
# You can generate the secret via 'openssl rand -base64 32' on Unix
|
||||||
# @see https://next-auth.js.org/configuration/options#secret
|
# @see https://next-auth.js.org/configuration/options#secret
|
||||||
|
|||||||
@@ -37,17 +37,17 @@
|
|||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@homarr/widgets": "workspace:^0.1.0",
|
"@homarr/widgets": "workspace:^0.1.0",
|
||||||
"@mantine/colors-generator": "^7.13.2",
|
"@mantine/colors-generator": "^7.13.3",
|
||||||
"@mantine/core": "^7.13.2",
|
"@mantine/core": "^7.13.3",
|
||||||
"@mantine/hooks": "^7.13.2",
|
"@mantine/hooks": "^7.13.3",
|
||||||
"@mantine/modals": "^7.13.2",
|
"@mantine/modals": "^7.13.3",
|
||||||
"@mantine/tiptap": "^7.13.2",
|
"@mantine/tiptap": "^7.13.3",
|
||||||
"@million/lint": "1.0.9",
|
"@million/lint": "1.0.11",
|
||||||
"@t3-oss/env-nextjs": "^0.11.1",
|
"@t3-oss/env-nextjs": "^0.11.1",
|
||||||
"@tabler/icons-react": "^3.19.0",
|
"@tabler/icons-react": "^3.19.0",
|
||||||
"@tanstack/react-query": "^5.59.9",
|
"@tanstack/react-query": "^5.59.15",
|
||||||
"@tanstack/react-query-devtools": "^5.59.9",
|
"@tanstack/react-query-devtools": "^5.59.15",
|
||||||
"@tanstack/react-query-next-experimental": "5.59.9",
|
"@tanstack/react-query-next-experimental": "5.59.15",
|
||||||
"@trpc/client": "next",
|
"@trpc/client": "next",
|
||||||
"@trpc/next": "next",
|
"@trpc/next": "next",
|
||||||
"@trpc/react-query": "next",
|
"@trpc/react-query": "next",
|
||||||
@@ -55,22 +55,22 @@
|
|||||||
"@xterm/addon-canvas": "^0.7.0",
|
"@xterm/addon-canvas": "^0.7.0",
|
||||||
"@xterm/addon-fit": "0.10.0",
|
"@xterm/addon-fit": "0.10.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
"chroma-js": "^3.1.1",
|
"chroma-js": "^3.1.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"flag-icons": "^7.2.3",
|
"flag-icons": "^7.2.3",
|
||||||
"glob": "^11.0.0",
|
"glob": "^11.0.0",
|
||||||
"jotai": "^2.10.0",
|
"jotai": "^2.10.1",
|
||||||
"mantine-react-table": "2.0.0-beta.6",
|
"mantine-react-table": "2.0.0-beta.7",
|
||||||
"next": "^14.2.15",
|
"next": "^14.2.15",
|
||||||
"postcss-preset-mantine": "^1.17.0",
|
"postcss-preset-mantine": "^1.17.0",
|
||||||
"prismjs": "^1.29.0",
|
"prismjs": "^1.29.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-error-boundary": "^4.0.13",
|
"react-error-boundary": "^4.1.1",
|
||||||
"react-simple-code-editor": "^0.14.1",
|
"react-simple-code-editor": "^0.14.1",
|
||||||
"sass": "^1.79.5",
|
"sass": "^1.80.2",
|
||||||
"superjson": "2.2.1",
|
"superjson": "2.2.1",
|
||||||
"swagger-ui-react": "^5.17.14",
|
"swagger-ui-react": "^5.17.14",
|
||||||
"use-deep-compare-effect": "^1.8.1"
|
"use-deep-compare-effect": "^1.8.1"
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/chroma-js": "2.4.4",
|
"@types/chroma-js": "2.4.4",
|
||||||
"@types/node": "^20.16.11",
|
"@types/node": "^20.16.12",
|
||||||
"@types/prismjs": "^1.26.4",
|
"@types/prismjs": "^1.26.4",
|
||||||
"@types/react": "^18.3.11",
|
"@types/react": "^18.3.11",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
|
|||||||
BIN
apps/nextjs/public/images/pwa/192.maskable.png
Normal file
BIN
apps/nextjs/public/images/pwa/192.maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
BIN
apps/nextjs/public/images/pwa/512.maskable.png
Normal file
BIN
apps/nextjs/public/images/pwa/512.maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -16,7 +16,7 @@ export const CustomMantineProvider = ({ children }: PropsWithChildren) => {
|
|||||||
return (
|
return (
|
||||||
<DirectionProvider>
|
<DirectionProvider>
|
||||||
<MantineProvider
|
<MantineProvider
|
||||||
defaultColorScheme="auto"
|
defaultColorScheme="dark"
|
||||||
colorSchemeManager={manager}
|
colorSchemeManager={manager}
|
||||||
theme={createTheme({
|
theme={createTheme({
|
||||||
primaryColor: "red",
|
primaryColor: "red",
|
||||||
@@ -62,6 +62,7 @@ function useColorSchemeManager(): MantineColorSchemeManager {
|
|||||||
},
|
},
|
||||||
|
|
||||||
set: (value) => {
|
set: (value) => {
|
||||||
|
if (value === "auto") return;
|
||||||
try {
|
try {
|
||||||
if (session) {
|
if (session) {
|
||||||
mutateColorScheme({ colorScheme: value });
|
mutateColorScheme({ colorScheme: value });
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export const BoardProvider = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setReadySections((previous) => previous.filter((id) => data.sections.some((section) => section.id === id)));
|
setReadySections((previous) => previous.filter((id) => data.sections.some((section) => section.id === id)));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [data.sections.length, setReadySections]);
|
}, [data.sections.length, setReadySections]);
|
||||||
|
|
||||||
const markAsReady = useCallback((id: string) => {
|
const markAsReady = useCallback((id: string) => {
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
|||||||
layout: createBoardLayout({
|
layout: createBoardLayout({
|
||||||
headerActions: <BoardContentHeaderActions />,
|
headerActions: <BoardContentHeaderActions />,
|
||||||
getInitialBoardAsync: getInitialBoard,
|
getInitialBoardAsync: getInitialBoard,
|
||||||
isBoardContentPage: true,
|
|
||||||
}),
|
}),
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
page: async () => {
|
page: async () => {
|
||||||
@@ -50,6 +49,10 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
|||||||
title: board.metaTitle ?? createMetaTitle(t("board.content.metaTitle", { boardName: board.name })),
|
title: board.metaTitle ?? createMetaTitle(t("board.content.metaTitle", { boardName: board.name })),
|
||||||
icons: {
|
icons: {
|
||||||
icon: board.faviconImageUrl ? board.faviconImageUrl : undefined,
|
icon: board.faviconImageUrl ? board.faviconImageUrl : undefined,
|
||||||
|
apple: board.faviconImageUrl ? board.faviconImageUrl : undefined,
|
||||||
|
},
|
||||||
|
appleWebApp: {
|
||||||
|
startupImage: { url: board.faviconImageUrl ? board.faviconImageUrl : "/logo/logo.png" },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -8,5 +8,4 @@ export default createBoardLayout<{ locale: string; name: string }>({
|
|||||||
async getInitialBoardAsync({ name }) {
|
async getInitialBoardAsync({ name }) {
|
||||||
return await api.board.getBoardByName({ name });
|
return await api.board.getBoardByName({ name });
|
||||||
},
|
},
|
||||||
isBoardContentPage: false,
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { AppShellMain } from "@mantine/core";
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
import { logger } from "@homarr/log";
|
import { logger } from "@homarr/log";
|
||||||
import { GlobalItemServerDataRunner } from "@homarr/widgets";
|
|
||||||
|
|
||||||
import { MainHeader } from "~/components/layout/header";
|
import { MainHeader } from "~/components/layout/header";
|
||||||
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
||||||
@@ -18,13 +17,11 @@ import { BoardMantineProvider } from "./(content)/_theme";
|
|||||||
interface CreateBoardLayoutProps<TParams extends Params> {
|
interface CreateBoardLayoutProps<TParams extends Params> {
|
||||||
headerActions: JSX.Element;
|
headerActions: JSX.Element;
|
||||||
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
||||||
isBoardContentPage: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createBoardLayout = <TParams extends Params>({
|
export const createBoardLayout = <TParams extends Params>({
|
||||||
headerActions,
|
headerActions,
|
||||||
getInitialBoardAsync: getInitialBoard,
|
getInitialBoardAsync: getInitialBoard,
|
||||||
isBoardContentPage,
|
|
||||||
}: CreateBoardLayoutProps<TParams>) => {
|
}: CreateBoardLayoutProps<TParams>) => {
|
||||||
const Layout = async ({
|
const Layout = async ({
|
||||||
params,
|
params,
|
||||||
@@ -42,21 +39,19 @@ export const createBoardLayout = <TParams extends Params>({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GlobalItemServerDataRunner board={initialBoard} shouldRun={isBoardContentPage}>
|
<BoardProvider initialBoard={initialBoard}>
|
||||||
<BoardProvider initialBoard={initialBoard}>
|
<BoardMantineProvider>
|
||||||
<BoardMantineProvider>
|
<CustomCss />
|
||||||
<CustomCss />
|
<ClientShell hasNavigation={false}>
|
||||||
<ClientShell hasNavigation={false}>
|
<MainHeader
|
||||||
<MainHeader
|
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
actions={headerActions}
|
||||||
actions={headerActions}
|
hasNavigation={false}
|
||||||
hasNavigation={false}
|
/>
|
||||||
/>
|
<AppShellMain>{children}</AppShellMain>
|
||||||
<AppShellMain>{children}</AppShellMain>
|
</ClientShell>
|
||||||
</ClientShell>
|
</BoardMantineProvider>
|
||||||
</BoardMantineProvider>
|
</BoardProvider>
|
||||||
</BoardProvider>
|
|
||||||
</GlobalItemServerDataRunner>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,7 @@ const fontSans = Inter({
|
|||||||
variable: "--font-sans",
|
variable: "--font-sans",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const generateMetadata = (): Metadata => ({
|
||||||
metadataBase: new URL("http://localhost:3000"),
|
|
||||||
title: "Homarr",
|
title: "Homarr",
|
||||||
description:
|
description:
|
||||||
"Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.",
|
"Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.",
|
||||||
@@ -40,12 +39,17 @@ export const metadata: Metadata = {
|
|||||||
url: "https://homarr.dev",
|
url: "https://homarr.dev",
|
||||||
siteName: "Homarr Documentation",
|
siteName: "Homarr Documentation",
|
||||||
},
|
},
|
||||||
twitter: {
|
icons: {
|
||||||
card: "summary_large_image",
|
icon: "/logo/logo.png",
|
||||||
site: "@jullerino",
|
apple: "/logo/logo.png",
|
||||||
creator: "@jullerino",
|
|
||||||
},
|
},
|
||||||
};
|
appleWebApp: {
|
||||||
|
title: "Homarr",
|
||||||
|
capable: true,
|
||||||
|
startupImage: { url: "/logo/logo.png" },
|
||||||
|
statusBarStyle: getColorScheme() === "dark" ? "black-translucent" : "default",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
themeColor: [
|
themeColor: [
|
||||||
@@ -56,7 +60,7 @@ export const viewport: Viewport = {
|
|||||||
|
|
||||||
export default async function Layout(props: { children: React.ReactNode; params: { locale: string } }) {
|
export default async function Layout(props: { children: React.ReactNode; params: { locale: string } }) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const colorScheme = cookies().get("homarr-color-scheme")?.value ?? "light";
|
const colorScheme = getColorScheme();
|
||||||
const tCommon = await getScopedI18n("common");
|
const tCommon = await getScopedI18n("common");
|
||||||
const direction = tCommon("direction");
|
const direction = tCommon("direction");
|
||||||
|
|
||||||
@@ -73,7 +77,15 @@ export default async function Layout(props: { children: React.ReactNode; params:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
|
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
|
||||||
<html lang="en" dir={direction} data-mantine-color-scheme={colorScheme} suppressHydrationWarning>
|
<html
|
||||||
|
lang="en"
|
||||||
|
dir={direction}
|
||||||
|
data-mantine-color-scheme={colorScheme}
|
||||||
|
style={{
|
||||||
|
backgroundColor: colorScheme === "dark" ? "#242424" : "#fff",
|
||||||
|
}}
|
||||||
|
suppressHydrationWarning
|
||||||
|
>
|
||||||
<head>
|
<head>
|
||||||
<Analytics />
|
<Analytics />
|
||||||
<SearchEngineOptimization />
|
<SearchEngineOptimization />
|
||||||
@@ -87,3 +99,7 @@ export default async function Layout(props: { children: React.ReactNode; params:
|
|||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getColorScheme = () => {
|
||||||
|
return cookies().get("homarr-color-scheme")?.value ?? "dark";
|
||||||
|
};
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { IconInfoCircle } from "@tabler/icons-react";
|
|||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
|
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
|
||||||
import { getAllSecretKindOptions } from "@homarr/definitions";
|
import { getAllSecretKindOptions, getIntegrationName } from "@homarr/definitions";
|
||||||
import type { UseFormReturnType } from "@homarr/form";
|
import type { UseFormReturnType } from "@homarr/form";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
|
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
|
||||||
@@ -32,7 +32,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const form = useZodForm(validation.integration.create.omit({ kind: true }), {
|
const form = useZodForm(validation.integration.create.omit({ kind: true }), {
|
||||||
initialValues: {
|
initialValues: {
|
||||||
name: searchParams.name ?? "",
|
name: searchParams.name ?? getIntegrationName(searchParams.kind),
|
||||||
url: searchParams.url ?? "",
|
url: searchParams.url ?? "",
|
||||||
secrets: secretKinds[0].map((kind) => ({
|
secrets: secretKinds[0].map((kind) => ({
|
||||||
kind,
|
kind,
|
||||||
@@ -81,7 +81,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
|
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
|
<TextInput withAsterisk label={t("integration.field.name.label")} autoFocus {...form.getInputProps("name")} />
|
||||||
|
|
||||||
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
|
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
|
||||||
|
|
||||||
|
|||||||
@@ -161,21 +161,20 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
|||||||
</TableTd>
|
</TableTd>
|
||||||
<TableTd>
|
<TableTd>
|
||||||
<Group justify="end">
|
<Group justify="end">
|
||||||
{hasFullAccess ||
|
{(hasFullAccess || integration.permissions.hasFullAccess) && (
|
||||||
(integration.permissions.hasFullAccess && (
|
<ActionIconGroup>
|
||||||
<ActionIconGroup>
|
<ActionIcon
|
||||||
<ActionIcon
|
component={Link}
|
||||||
component={Link}
|
href={`/manage/integrations/edit/${integration.id}`}
|
||||||
href={`/manage/integrations/edit/${integration.id}`}
|
variant="subtle"
|
||||||
variant="subtle"
|
color="gray"
|
||||||
color="gray"
|
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
|
||||||
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
|
>
|
||||||
>
|
<IconPencil size={16} stroke={1.5} />
|
||||||
<IconPencil size={16} stroke={1.5} />
|
</ActionIcon>
|
||||||
</ActionIcon>
|
<DeleteIntegrationActionButton integration={integration} count={integrations.length} />
|
||||||
<DeleteIntegrationActionButton integration={integration} count={integrations.length} />
|
</ActionIconGroup>
|
||||||
</ActionIconGroup>
|
)}
|
||||||
))}
|
|
||||||
</Group>
|
</Group>
|
||||||
</TableTd>
|
</TableTd>
|
||||||
</TableTr>
|
</TableTr>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = useMantineReactTable({
|
const table = useMantineReactTable({
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button, Group, Stack, Switch } from "@mantine/core";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import { useZodForm } from "@homarr/form";
|
||||||
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import type { z } from "@homarr/validation";
|
||||||
|
import { validation } from "@homarr/validation";
|
||||||
|
|
||||||
|
interface PingIconsEnabledProps {
|
||||||
|
user: RouterOutputs["user"]["getById"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PingIconsEnabled = ({ user }: PingIconsEnabledProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const { mutate, isPending } = clientApi.user.changePingIconsEnabled.useMutation({
|
||||||
|
async onSettled() {
|
||||||
|
await revalidatePathActionAsync(`/manage/users/${user.id}`);
|
||||||
|
},
|
||||||
|
onSuccess(_, variables) {
|
||||||
|
form.setInitialValues({
|
||||||
|
pingIconsEnabled: variables.pingIconsEnabled,
|
||||||
|
});
|
||||||
|
showSuccessNotification({
|
||||||
|
message: t("user.action.changePingIconsEnabled.notification.success.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError() {
|
||||||
|
showErrorNotification({
|
||||||
|
message: t("user.action.changePingIconsEnabled.notification.error.message"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const form = useZodForm(validation.user.pingIconsEnabled, {
|
||||||
|
initialValues: {
|
||||||
|
pingIconsEnabled: user.pingIconsEnabled,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (values: FormType) => {
|
||||||
|
mutate({
|
||||||
|
id: user.id,
|
||||||
|
...values,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Switch {...form.getInputProps("pingIconsEnabled")} label={t("user.field.pingIconsEnabled.label")} />
|
||||||
|
|
||||||
|
<Group justify="end">
|
||||||
|
<Button type="submit" color="teal" loading={isPending}>
|
||||||
|
{t("common.action.save")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormType = z.infer<typeof validation.user.pingIconsEnabled>;
|
||||||
@@ -61,7 +61,7 @@ export const UserProfileForm = ({ user }: UserProfileFormProps) => {
|
|||||||
id: user.id,
|
id: user.id,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[user.id, mutate],
|
[isProviderCredentials, mutate, user.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { canAccessUserEditPage } from "../access";
|
|||||||
import { ChangeHomeBoardForm } from "./_components/_change-home-board";
|
import { ChangeHomeBoardForm } from "./_components/_change-home-board";
|
||||||
import { DeleteUserButton } from "./_components/_delete-user-button";
|
import { DeleteUserButton } from "./_components/_delete-user-button";
|
||||||
import { FirstDayOfWeek } from "./_components/_first-day-of-week";
|
import { FirstDayOfWeek } from "./_components/_first-day-of-week";
|
||||||
|
import { PingIconsEnabled } from "./_components/_ping-icons-enabled";
|
||||||
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
|
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
|
||||||
import { UserProfileForm } from "./_components/_profile-form";
|
import { UserProfileForm } from "./_components/_profile-form";
|
||||||
|
|
||||||
@@ -99,6 +100,11 @@ export default async function EditUserPage({ params }: Props) {
|
|||||||
<FirstDayOfWeek user={user} />
|
<FirstDayOfWeek user={user} />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
<Stack mb="lg">
|
||||||
|
<Title order={2}>{tGeneral("item.accessibility")}</Title>
|
||||||
|
<PingIconsEnabled user={user} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
{isCredentialsUser && (
|
{isCredentialsUser && (
|
||||||
<DangerZoneRoot>
|
<DangerZoneRoot>
|
||||||
<DangerZoneItem
|
<DangerZoneItem
|
||||||
|
|||||||
39
apps/nextjs/src/app/manifest.ts
Normal file
39
apps/nextjs/src/app/manifest.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
|
return {
|
||||||
|
name: "Homarr",
|
||||||
|
short_name: "Homarr",
|
||||||
|
description: "Your dashboard for managing your server.",
|
||||||
|
start_url: "/",
|
||||||
|
display: "standalone",
|
||||||
|
background_color: "#fff",
|
||||||
|
theme_color: "#fff",
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: "/images/pwa/192.maskable.png",
|
||||||
|
sizes: "192x192",
|
||||||
|
type: "image/png",
|
||||||
|
purpose: "any",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/images/pwa/192.maskable.png",
|
||||||
|
sizes: "192x192",
|
||||||
|
type: "image/png",
|
||||||
|
purpose: "maskable",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/images/pwa/512.maskable.png",
|
||||||
|
sizes: "512x512",
|
||||||
|
type: "image/png",
|
||||||
|
purpose: "any",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/images/pwa/512.maskable.png",
|
||||||
|
sizes: "512x512",
|
||||||
|
type: "image/png",
|
||||||
|
purpose: "maskable",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { QueryErrorResetBoundary } from "@tanstack/react-query";
|
|||||||
import combineClasses from "clsx";
|
import combineClasses from "clsx";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
|
|
||||||
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, useServerDataFor } from "@homarr/widgets";
|
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues } from "@homarr/widgets";
|
||||||
import { WidgetError } from "@homarr/widgets/errors";
|
import { WidgetError } from "@homarr/widgets/errors";
|
||||||
|
|
||||||
import type { Item } from "~/app/[locale]/boards/_types";
|
import type { Item } from "~/app/[locale]/boards/_types";
|
||||||
@@ -53,7 +53,6 @@ interface InnerContentProps {
|
|||||||
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const [isEditMode] = useEditMode();
|
const [isEditMode] = useEditMode();
|
||||||
const serverData = useServerDataFor(item.id);
|
|
||||||
const Comp = loadWidgetDynamic(item.kind);
|
const Comp = loadWidgetDynamic(item.kind);
|
||||||
const options = reduceWidgetOptionsWithDefaultValues(item.kind, item.options);
|
const options = reduceWidgetOptionsWithDefaultValues(item.kind, item.options);
|
||||||
const newItem = { ...item, options };
|
const newItem = { ...item, options };
|
||||||
@@ -61,8 +60,6 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
|||||||
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
|
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
|
||||||
updateItemOptions({ itemId: item.id, newOptions });
|
updateItemOptions({ itemId: item.id, newOptions });
|
||||||
|
|
||||||
if (!serverData?.isReady) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryErrorResetBoundary>
|
<QueryErrorResetBoundary>
|
||||||
{({ reset }) => (
|
{({ reset }) => (
|
||||||
@@ -79,8 +76,6 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
|||||||
<Comp
|
<Comp
|
||||||
options={options as never}
|
options={options as never}
|
||||||
integrationIds={item.integrationIds}
|
integrationIds={item.integrationIds}
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
||||||
serverData={serverData?.data as never}
|
|
||||||
isEditMode={isEditMode}
|
isEditMode={isEditMode}
|
||||||
boardId={board.id}
|
boardId={board.id}
|
||||||
itemId={item.id}
|
itemId={item.id}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
|||||||
import { z } from "@homarr/validation";
|
import { z } from "@homarr/validation";
|
||||||
|
|
||||||
import type { Item } from "~/app/[locale]/boards/_types";
|
import type { Item } from "~/app/[locale]/boards/_types";
|
||||||
import { useItemActions } from "./item-actions";
|
|
||||||
|
|
||||||
interface InnerProps {
|
interface InnerProps {
|
||||||
gridStack: GridStack;
|
gridStack: GridStack;
|
||||||
@@ -21,7 +20,6 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
|
|||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
// Keep track of the maximum width based on the x offset
|
// Keep track of the maximum width based on the x offset
|
||||||
const maxWidthRef = useRef(innerProps.columnCount - innerProps.item.xOffset);
|
const maxWidthRef = useRef(innerProps.columnCount - innerProps.item.xOffset);
|
||||||
const { moveAndResizeItem } = useItemActions();
|
|
||||||
const form = useZodForm(
|
const form = useZodForm(
|
||||||
z.object({
|
z.object({
|
||||||
xOffset: z
|
xOffset: z
|
||||||
@@ -62,7 +60,7 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
|
|||||||
});
|
});
|
||||||
actions.closeModal();
|
actions.closeModal();
|
||||||
},
|
},
|
||||||
[moveAndResizeItem],
|
[actions, innerProps.gridStack, innerProps.item.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export const GridStackItem = ({
|
|||||||
if (type !== "section") return;
|
if (type !== "section") return;
|
||||||
innerRef.current.gridstackNode.minW = minWidth;
|
innerRef.current.gridstackNode.minW = minWidth;
|
||||||
innerRef.current.gridstackNode.minH = minHeight;
|
innerRef.current.gridstackNode.minH = minHeight;
|
||||||
}, [minWidth, minHeight, innerRef]);
|
}, [minWidth, minHeight, innerRef, type]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only run this effect when the section items change
|
// Only run this effect when the section items change
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [itemIds.length, columnCount]);
|
}, [itemIds.length, columnCount]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export const UserAvatarMenu = ({ children }: UserAvatarMenuProps) => {
|
|||||||
router.refresh();
|
router.refresh();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [openModal, router]);
|
}, [logoutUrl, openModal, router]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu width={300} withArrow withinPortal>
|
<Menu width={300} withArrow withinPortal>
|
||||||
|
|||||||
@@ -38,13 +38,13 @@
|
|||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"superjson": "2.2.1",
|
"superjson": "2.2.1",
|
||||||
"undici": "6.20.0"
|
"undici": "6.20.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/node": "^20.16.11",
|
"@types/node": "^20.16.12",
|
||||||
"dotenv-cli": "^7.4.2",
|
"dotenv-cli": "^7.4.2",
|
||||||
"eslint": "^9.12.0",
|
"eslint": "^9.12.0",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
|
|||||||
@@ -29,8 +29,8 @@
|
|||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@turbo/gen": "^2.1.3",
|
"@turbo/gen": "^2.1.3",
|
||||||
"@vitejs/plugin-react": "^4.3.2",
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
"@vitest/coverage-v8": "^2.1.2",
|
"@vitest/coverage-v8": "^2.1.3",
|
||||||
"@vitest/ui": "^2.1.2",
|
"@vitest/ui": "^2.1.3",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"jsdom": "^25.0.1",
|
"jsdom": "^25.0.1",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
@@ -38,9 +38,9 @@
|
|||||||
"turbo": "^2.1.3",
|
"turbo": "^2.1.3",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"vite-tsconfig-paths": "^5.0.1",
|
"vite-tsconfig-paths": "^5.0.1",
|
||||||
"vitest": "^2.1.2"
|
"vitest": "^2.1.3"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@9.12.1",
|
"packageManager": "pnpm@9.12.2",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.18.0"
|
"node": ">=20.18.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -248,18 +248,11 @@ describe("editProfile shoud update user", () => {
|
|||||||
const user = await db.select().from(schema.users).where(eq(schema.users.id, defaultOwnerId));
|
const user = await db.select().from(schema.users).where(eq(schema.users.id, defaultOwnerId));
|
||||||
|
|
||||||
expect(user).toHaveLength(1);
|
expect(user).toHaveLength(1);
|
||||||
expect(user[0]).toStrictEqual({
|
expect(user[0]).containSubset({
|
||||||
id: defaultOwnerId,
|
id: defaultOwnerId,
|
||||||
name: "ABC",
|
name: "ABC",
|
||||||
email: "abc@gmail.com",
|
email: "abc@gmail.com",
|
||||||
emailVerified,
|
emailVerified,
|
||||||
salt: null,
|
|
||||||
password: null,
|
|
||||||
image: null,
|
|
||||||
homeBoardId: null,
|
|
||||||
provider: "credentials",
|
|
||||||
colorScheme: "auto",
|
|
||||||
firstDayOfWeek: 1,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -289,18 +282,11 @@ describe("editProfile shoud update user", () => {
|
|||||||
const user = await db.select().from(schema.users).where(eq(schema.users.id, defaultOwnerId));
|
const user = await db.select().from(schema.users).where(eq(schema.users.id, defaultOwnerId));
|
||||||
|
|
||||||
expect(user).toHaveLength(1);
|
expect(user).toHaveLength(1);
|
||||||
expect(user[0]).toStrictEqual({
|
expect(user[0]).containSubset({
|
||||||
id: defaultOwnerId,
|
id: defaultOwnerId,
|
||||||
name: "ABC",
|
name: "ABC",
|
||||||
email: "myNewEmail@gmail.com",
|
email: "myNewEmail@gmail.com",
|
||||||
emailVerified: null,
|
emailVerified: null,
|
||||||
salt: null,
|
|
||||||
password: null,
|
|
||||||
image: null,
|
|
||||||
homeBoardId: null,
|
|
||||||
provider: "credentials",
|
|
||||||
colorScheme: "auto",
|
|
||||||
firstDayOfWeek: 1,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -317,40 +303,14 @@ describe("delete should delete user", () => {
|
|||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
name: "User 1",
|
name: "User 1",
|
||||||
email: null,
|
|
||||||
emailVerified: null,
|
|
||||||
image: null,
|
|
||||||
password: null,
|
|
||||||
salt: null,
|
|
||||||
homeBoardId: null,
|
|
||||||
provider: "ldap" as const,
|
|
||||||
colorScheme: "auto" as const,
|
|
||||||
firstDayOfWeek: 1 as const,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: defaultOwnerId,
|
id: defaultOwnerId,
|
||||||
name: "User 2",
|
name: "User 2",
|
||||||
email: null,
|
|
||||||
emailVerified: null,
|
|
||||||
image: null,
|
|
||||||
password: null,
|
|
||||||
salt: null,
|
|
||||||
homeBoardId: null,
|
|
||||||
colorScheme: "auto" as const,
|
|
||||||
firstDayOfWeek: 1 as const,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
name: "User 3",
|
name: "User 3",
|
||||||
email: null,
|
|
||||||
emailVerified: null,
|
|
||||||
image: null,
|
|
||||||
password: null,
|
|
||||||
salt: null,
|
|
||||||
homeBoardId: null,
|
|
||||||
provider: "oidc" as const,
|
|
||||||
colorScheme: "auto" as const,
|
|
||||||
firstDayOfWeek: 1 as const,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -359,6 +319,8 @@ describe("delete should delete user", () => {
|
|||||||
await caller.delete(defaultOwnerId);
|
await caller.delete(defaultOwnerId);
|
||||||
|
|
||||||
const usersInDb = await db.select().from(schema.users);
|
const usersInDb = await db.select().from(schema.users);
|
||||||
expect(usersInDb).toStrictEqual([initialUsers[0], initialUsers[2]]);
|
expect(usersInDb).toHaveLength(2);
|
||||||
|
expect(usersInDb[0]).containSubset(initialUsers[0]);
|
||||||
|
expect(usersInDb[1]).containSubset(initialUsers[2]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ export const userRouter = createTRPCRouter({
|
|||||||
provider: true,
|
provider: true,
|
||||||
homeBoardId: true,
|
homeBoardId: true,
|
||||||
firstDayOfWeek: true,
|
firstDayOfWeek: true,
|
||||||
|
pingIconsEnabled: true,
|
||||||
},
|
},
|
||||||
where: eq(users.id, input.userId),
|
where: eq(users.id, input.userId),
|
||||||
});
|
});
|
||||||
@@ -376,6 +377,39 @@ export const userRouter = createTRPCRouter({
|
|||||||
})
|
})
|
||||||
.where(eq(users.id, ctx.session.user.id));
|
.where(eq(users.id, ctx.session.user.id));
|
||||||
}),
|
}),
|
||||||
|
getPingIconsEnabledOrDefault: publicProcedure.query(async ({ ctx }) => {
|
||||||
|
if (!ctx.session?.user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await ctx.db.query.users.findFirst({
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
pingIconsEnabled: true,
|
||||||
|
},
|
||||||
|
where: eq(users.id, ctx.session.user.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
return user?.pingIconsEnabled ?? false;
|
||||||
|
}),
|
||||||
|
changePingIconsEnabled: protectedProcedure
|
||||||
|
.input(validation.user.pingIconsEnabled.and(validation.common.byId))
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
// Only admins can change other users ping icons enabled
|
||||||
|
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "User not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
pingIconsEnabled: input.pingIconsEnabled,
|
||||||
|
})
|
||||||
|
.where(eq(users.id, ctx.session.user.id));
|
||||||
|
}),
|
||||||
getFirstDayOfWeekForUserOrDefault: publicProcedure.query(async ({ ctx }) => {
|
getFirstDayOfWeekForUserOrDefault: publicProcedure.query(async ({ ctx }) => {
|
||||||
if (!ctx.session?.user) {
|
if (!ctx.session?.user) {
|
||||||
return 1 as const;
|
return 1 as const;
|
||||||
@@ -394,7 +428,7 @@ export const userRouter = createTRPCRouter({
|
|||||||
changeFirstDayOfWeek: protectedProcedure
|
changeFirstDayOfWeek: protectedProcedure
|
||||||
.input(validation.user.firstDayOfWeek.and(validation.common.byId))
|
.input(validation.user.firstDayOfWeek.and(validation.common.byId))
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
// Only admins can change other users' passwords
|
// Only admins can change other users first day of week
|
||||||
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
|
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ export const calendarRouter = createTRPCRouter({
|
|||||||
findAllEvents: publicProcedure
|
findAllEvents: publicProcedure
|
||||||
.unstable_concat(createManyIntegrationOfOneItemMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
|
.unstable_concat(createManyIntegrationOfOneItemMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
|
||||||
.query(async ({ ctx }) => {
|
.query(async ({ ctx }) => {
|
||||||
return await Promise.all(
|
const result = await Promise.all(
|
||||||
ctx.integrations.flatMap(async (integration) => {
|
ctx.integrations.flatMap(async (integration) => {
|
||||||
const cache = createItemAndIntegrationChannel<CalendarEvent[]>("calendar", integration.id);
|
const cache = createItemAndIntegrationChannel<CalendarEvent[]>("calendar", integration.id);
|
||||||
return await cache.getAsync();
|
return await cache.getAsync();
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
return result.filter((item) => item !== null).flatMap((item) => item.data);
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,15 +13,13 @@ export const healthMonitoringRouter = createTRPCRouter({
|
|||||||
return await Promise.all(
|
return await Promise.all(
|
||||||
ctx.integrations.map(async (integration) => {
|
ctx.integrations.map(async (integration) => {
|
||||||
const channel = createItemAndIntegrationChannel<HealthMonitoring>("healthMonitoring", integration.id);
|
const channel = createItemAndIntegrationChannel<HealthMonitoring>("healthMonitoring", integration.id);
|
||||||
const data = await channel.getAsync();
|
const { data: healthInfo, timestamp } = (await channel.getAsync()) ?? { data: null, timestamp: new Date(0) };
|
||||||
if (!data) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
integrationId: integration.id,
|
integrationId: integration.id,
|
||||||
integrationName: integration.name,
|
integrationName: integration.name,
|
||||||
healthInfo: data.data,
|
healthInfo,
|
||||||
|
timestamp,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -30,7 +28,7 @@ export const healthMonitoringRouter = createTRPCRouter({
|
|||||||
subscribeHealthStatus: publicProcedure
|
subscribeHealthStatus: publicProcedure
|
||||||
.unstable_concat(createManyIntegrationMiddleware("query", "openmediavault"))
|
.unstable_concat(createManyIntegrationMiddleware("query", "openmediavault"))
|
||||||
.subscription(({ ctx }) => {
|
.subscription(({ ctx }) => {
|
||||||
return observable<{ integrationId: string; healthInfo: HealthMonitoring }>((emit) => {
|
return observable<{ integrationId: string; healthInfo: HealthMonitoring; timestamp: Date }>((emit) => {
|
||||||
const unsubscribes: (() => void)[] = [];
|
const unsubscribes: (() => void)[] = [];
|
||||||
for (const integration of ctx.integrations) {
|
for (const integration of ctx.integrations) {
|
||||||
const channel = createItemAndIntegrationChannel<HealthMonitoring>("healthMonitoring", integration.id);
|
const channel = createItemAndIntegrationChannel<HealthMonitoring>("healthMonitoring", integration.id);
|
||||||
@@ -38,6 +36,7 @@ export const healthMonitoringRouter = createTRPCRouter({
|
|||||||
emit.next({
|
emit.next({
|
||||||
integrationId: integration.id,
|
integrationId: integration.id,
|
||||||
healthInfo,
|
healthInfo,
|
||||||
|
timestamp: new Date(0),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
unsubscribes.push(unsubscribe);
|
unsubscribes.push(unsubscribe);
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const createSessionAsync = async (
|
|||||||
...user,
|
...user,
|
||||||
email: user.email ?? "",
|
email: user.email ?? "",
|
||||||
permissions: await getCurrentUserPermissionsAsync(db, user.id),
|
permissions: await getCurrentUserPermissionsAsync(db, user.id),
|
||||||
colorScheme: "auto",
|
colorScheme: "dark",
|
||||||
},
|
},
|
||||||
} as Session;
|
} as Session;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,6 +34,17 @@ export const createConfiguration = (provider: SupportedAuthProvider | "unknown",
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
trustHost: true,
|
trustHost: true,
|
||||||
|
cookies: {
|
||||||
|
sessionToken: {
|
||||||
|
name: sessionTokenCookieName,
|
||||||
|
options: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
secure: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
adapter,
|
adapter,
|
||||||
providers: filterProviders([
|
providers: filterProviders([
|
||||||
Credentials(createCredentialsConfiguration(db)),
|
Credentials(createCredentialsConfiguration(db)),
|
||||||
|
|||||||
@@ -23,8 +23,8 @@
|
|||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config",
|
"prettier": "@homarr/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/core": "^0.37.0",
|
"@auth/core": "^0.37.1",
|
||||||
"@auth/drizzle-adapter": "^1.7.0",
|
"@auth/drizzle-adapter": "^1.7.1",
|
||||||
"@homarr/common": "workspace:^0.1.0",
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
"@homarr/db": "workspace:^0.1.0",
|
"@homarr/db": "workspace:^0.1.0",
|
||||||
"@homarr/definitions": "workspace:^0.1.0",
|
"@homarr/definitions": "workspace:^0.1.0",
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
"cookies": "^0.9.1",
|
"cookies": "^0.9.1",
|
||||||
"ldapts": "7.2.1",
|
"ldapts": "7.2.1",
|
||||||
"next": "^14.2.15",
|
"next": "^14.2.15",
|
||||||
"next-auth": "5.0.0-beta.22",
|
"next-auth": "5.0.0-beta.23",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"next": "^14.2.15",
|
"next": "^14.2.15",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"tldts": "^6.1.50"
|
"tldts": "^6.1.52"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
|
|||||||
1
packages/db/migrations/mysql/0011_freezing_banshee.sql
Normal file
1
packages/db/migrations/mysql/0011_freezing_banshee.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `user` ADD `pingIconsEnabled` boolean DEFAULT false NOT NULL;
|
||||||
1497
packages/db/migrations/mysql/meta/0011_snapshot.json
Normal file
1497
packages/db/migrations/mysql/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,13 @@
|
|||||||
"when": 1728142597094,
|
"when": 1728142597094,
|
||||||
"tag": "0010_melted_pestilence",
|
"tag": "0010_melted_pestilence",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 11,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1728490046896,
|
||||||
|
"tag": "0011_freezing_banshee",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
1
packages/db/migrations/sqlite/0011_classy_angel.sql
Normal file
1
packages/db/migrations/sqlite/0011_classy_angel.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `user` ADD `pingIconsEnabled` integer DEFAULT false NOT NULL;
|
||||||
1430
packages/db/migrations/sqlite/meta/0011_snapshot.json
Normal file
1430
packages/db/migrations/sqlite/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,13 @@
|
|||||||
"when": 1728142590232,
|
"when": 1728142590232,
|
||||||
"tag": "0010_gorgeous_stingray",
|
"tag": "0010_gorgeous_stingray",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 11,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1728490026154,
|
||||||
|
"tag": "0011_classy_angel",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,10 @@
|
|||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"migration:mysql:generate": "drizzle-kit generate --config ./configs/mysql.config.ts",
|
"migration:mysql:generate": "drizzle-kit generate --config ./configs/mysql.config.ts",
|
||||||
"migration:mysql:run": "drizzle-kit migrate --config ./configs/mysql.config.ts",
|
"migration:mysql:run": "drizzle-kit migrate --config ./configs/mysql.config.ts",
|
||||||
|
"migration:mysql:drop": "drizzle-kit drop --config ./configs/mysql.config.ts",
|
||||||
"migration:sqlite:generate": "drizzle-kit generate --config ./configs/sqlite.config.ts",
|
"migration:sqlite:generate": "drizzle-kit generate --config ./configs/sqlite.config.ts",
|
||||||
"migration:sqlite:run": "drizzle-kit migrate --config ./configs/sqlite.config.ts",
|
"migration:sqlite:run": "drizzle-kit migrate --config ./configs/sqlite.config.ts",
|
||||||
|
"migration:sqlite:drop": "drizzle-kit drop --config ./configs/sqlite.config.ts",
|
||||||
"push:mysql": "drizzle-kit push --config ./configs/mysql.config.ts",
|
"push:mysql": "drizzle-kit push --config ./configs/mysql.config.ts",
|
||||||
"push:sqlite": "drizzle-kit push --config ./configs/sqlite.config.ts",
|
"push:sqlite": "drizzle-kit push --config ./configs/sqlite.config.ts",
|
||||||
"studio": "drizzle-kit studio --config ./configs/sqlite.config.ts",
|
"studio": "drizzle-kit studio --config ./configs/sqlite.config.ts",
|
||||||
@@ -31,16 +33,16 @@
|
|||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config",
|
"prettier": "@homarr/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/core": "^0.37.0",
|
"@auth/core": "^0.37.1",
|
||||||
"@homarr/common": "workspace:^0.1.0",
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
"@homarr/definitions": "workspace:^0.1.0",
|
"@homarr/definitions": "workspace:^0.1.0",
|
||||||
"@homarr/log": "workspace:^0.1.0",
|
"@homarr/log": "workspace:^0.1.0",
|
||||||
"@paralleldrive/cuid2": "^2.2.2",
|
"@paralleldrive/cuid2": "^2.2.2",
|
||||||
"@testcontainers/mysql": "^10.13.2",
|
"@testcontainers/mysql": "^10.13.2",
|
||||||
"better-sqlite3": "^11.3.0",
|
"better-sqlite3": "^11.4.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"drizzle-kit": "^0.25.0",
|
"drizzle-kit": "^0.26.2",
|
||||||
"drizzle-orm": "^0.34.1",
|
"drizzle-orm": "^0.35.2",
|
||||||
"mysql2": "3.11.3"
|
"mysql2": "3.11.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -43,8 +43,9 @@ export const users = mysqlTable("user", {
|
|||||||
homeBoardId: varchar("homeBoardId", { length: 64 }).references((): AnyMySqlColumn => boards.id, {
|
homeBoardId: varchar("homeBoardId", { length: 64 }).references((): AnyMySqlColumn => boards.id, {
|
||||||
onDelete: "set null",
|
onDelete: "set null",
|
||||||
}),
|
}),
|
||||||
colorScheme: varchar("colorScheme", { length: 5 }).$type<ColorScheme>().default("auto").notNull(),
|
colorScheme: varchar("colorScheme", { length: 5 }).$type<ColorScheme>().default("dark").notNull(),
|
||||||
firstDayOfWeek: tinyint("firstDayOfWeek").$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
firstDayOfWeek: tinyint("firstDayOfWeek").$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
||||||
|
pingIconsEnabled: boolean("pingIconsEnabled").default(false).notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const accounts = mysqlTable(
|
export const accounts = mysqlTable(
|
||||||
|
|||||||
@@ -44,8 +44,9 @@ export const users = sqliteTable("user", {
|
|||||||
homeBoardId: text("homeBoardId").references((): AnySQLiteColumn => boards.id, {
|
homeBoardId: text("homeBoardId").references((): AnySQLiteColumn => boards.id, {
|
||||||
onDelete: "set null",
|
onDelete: "set null",
|
||||||
}),
|
}),
|
||||||
colorScheme: text("colorScheme").$type<ColorScheme>().default("auto").notNull(),
|
colorScheme: text("colorScheme").$type<ColorScheme>().default("dark").notNull(),
|
||||||
firstDayOfWeek: int("firstDayOfWeek").$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
firstDayOfWeek: int("firstDayOfWeek").$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
|
||||||
|
pingIconsEnabled: int("pingIconsEnabled", { mode: "boolean" }).default(false).notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const accounts = sqliteTable(
|
export const accounts = sqliteTable(
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
export const colorSchemes = ["light", "dark", "auto"] as const;
|
export const colorSchemes = ["light", "dark"] as const;
|
||||||
export type ColorScheme = (typeof colorSchemes)[number];
|
export type ColorScheme = (typeof colorSchemes)[number];
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@mantine/form": "^7.13.2"
|
"@mantine/form": "^7.13.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
|
export const radarrReleaseTypes = ["inCinemas", "digitalRelease", "physicalRelease"] as const;
|
||||||
|
type ReleaseType = (typeof radarrReleaseTypes)[number];
|
||||||
|
|
||||||
export interface CalendarEvent {
|
export interface CalendarEvent {
|
||||||
name: string;
|
name: string;
|
||||||
subName: string;
|
subName: string;
|
||||||
date: Date;
|
date: Date;
|
||||||
|
dates?: { type: ReleaseType; date: Date }[];
|
||||||
description?: string;
|
description?: string;
|
||||||
thumbnail?: string;
|
thumbnail?: string;
|
||||||
mediaInformation?: {
|
mediaInformation?: {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import type { AtLeastOneOf } from "@homarr/common/types";
|
||||||
import { logger } from "@homarr/log";
|
import { logger } from "@homarr/log";
|
||||||
import { z } from "@homarr/validation";
|
import { z } from "@homarr/validation";
|
||||||
|
|
||||||
import { Integration } from "../../base/integration";
|
import { Integration } from "../../base/integration";
|
||||||
import type { CalendarEvent } from "../../calendar-types";
|
import type { CalendarEvent } from "../../calendar-types";
|
||||||
|
import { radarrReleaseTypes } from "../../calendar-types";
|
||||||
|
|
||||||
export class RadarrIntegration extends Integration {
|
export class RadarrIntegration extends Integration {
|
||||||
/**
|
/**
|
||||||
@@ -37,19 +39,23 @@ export class RadarrIntegration extends Integration {
|
|||||||
});
|
});
|
||||||
const radarrCalendarEvents = await z.array(radarrCalendarEventSchema).parseAsync(await response.json());
|
const radarrCalendarEvents = await z.array(radarrCalendarEventSchema).parseAsync(await response.json());
|
||||||
|
|
||||||
return radarrCalendarEvents.map(
|
return radarrCalendarEvents.map((radarrCalendarEvent): CalendarEvent => {
|
||||||
(radarrCalendarEvent): CalendarEvent => ({
|
const dates = radarrReleaseTypes
|
||||||
|
.map((type) => (radarrCalendarEvent[type] ? { type, date: radarrCalendarEvent[type] } : undefined))
|
||||||
|
.filter((date) => date) as AtLeastOneOf<Exclude<CalendarEvent["dates"], undefined>[number]>;
|
||||||
|
return {
|
||||||
name: radarrCalendarEvent.title,
|
name: radarrCalendarEvent.title,
|
||||||
subName: radarrCalendarEvent.originalTitle,
|
subName: radarrCalendarEvent.originalTitle,
|
||||||
description: radarrCalendarEvent.overview,
|
description: radarrCalendarEvent.overview,
|
||||||
thumbnail: this.chooseBestImageAsURL(radarrCalendarEvent),
|
thumbnail: this.chooseBestImageAsURL(radarrCalendarEvent),
|
||||||
date: radarrCalendarEvent.inCinemas,
|
date: dates[0].date,
|
||||||
|
dates,
|
||||||
mediaInformation: {
|
mediaInformation: {
|
||||||
type: "movie",
|
type: "movie",
|
||||||
},
|
},
|
||||||
links: this.getLinksForRadarrCalendarEvent(radarrCalendarEvent),
|
links: this.getLinksForRadarrCalendarEvent(radarrCalendarEvent),
|
||||||
}),
|
};
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private getLinksForRadarrCalendarEvent = (event: z.infer<typeof radarrCalendarEventSchema>) => {
|
private getLinksForRadarrCalendarEvent = (event: z.infer<typeof radarrCalendarEventSchema>) => {
|
||||||
@@ -118,7 +124,18 @@ const radarrCalendarEventImageSchema = z.array(
|
|||||||
const radarrCalendarEventSchema = z.object({
|
const radarrCalendarEventSchema = z.object({
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
originalTitle: z.string(),
|
originalTitle: z.string(),
|
||||||
inCinemas: z.string().transform((value) => new Date(value)),
|
inCinemas: z
|
||||||
|
.string()
|
||||||
|
.transform((value) => new Date(value))
|
||||||
|
.optional(),
|
||||||
|
physicalRelease: z
|
||||||
|
.string()
|
||||||
|
.transform((value) => new Date(value))
|
||||||
|
.optional(),
|
||||||
|
digitalRelease: z
|
||||||
|
.string()
|
||||||
|
.transform((value) => new Date(value))
|
||||||
|
.optional(),
|
||||||
overview: z.string().optional(),
|
overview: z.string().optional(),
|
||||||
titleSlug: z.string(),
|
titleSlug: z.string(),
|
||||||
images: radarrCalendarEventImageSchema,
|
images: radarrCalendarEventImageSchema,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@mantine/core": "^7.13.2",
|
"@mantine/core": "^7.13.3",
|
||||||
"@tabler/icons-react": "^3.19.0",
|
"@tabler/icons-react": "^3.19.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"next": "^14.2.15",
|
"next": "^14.2.15",
|
||||||
|
|||||||
@@ -24,8 +24,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@mantine/core": "^7.13.2",
|
"@mantine/core": "^7.13.3",
|
||||||
"@mantine/hooks": "^7.13.2",
|
"@mantine/hooks": "^7.13.3",
|
||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export const ConfirmModal = createModal<Omit<ConfirmModalProps, "title">>(({ act
|
|||||||
actions.closeModal();
|
actions.closeModal();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[cancelProps?.onClick, onCancel, actions.closeModal],
|
[cancelProps, onCancel, closeOnCancel, actions],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleConfirm = useCallback(
|
const handleConfirm = useCallback(
|
||||||
@@ -73,7 +73,7 @@ export const ConfirmModal = createModal<Omit<ConfirmModalProps, "title">>(({ act
|
|||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
},
|
},
|
||||||
[confirmProps?.onClick, onConfirm, actions.closeModal],
|
[confirmProps, onConfirm, closeOnConfirm, actions],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export const ModalProvider = ({ children }: PropsWithChildren) => {
|
|||||||
(id: string, canceled?: boolean) => {
|
(id: string, canceled?: boolean) => {
|
||||||
dispatch({ type: "CLOSE", modalId: id, canceled });
|
dispatch({ type: "CLOSE", modalId: id, canceled });
|
||||||
},
|
},
|
||||||
[stateRef, dispatch],
|
[dispatch],
|
||||||
);
|
);
|
||||||
|
|
||||||
const openModalInner: ModalContextProps["openModalInner"] = useCallback(
|
const openModalInner: ModalContextProps["openModalInner"] = useCallback(
|
||||||
@@ -63,10 +63,7 @@ export const ModalProvider = ({ children }: PropsWithChildren) => {
|
|||||||
[dispatch],
|
[dispatch],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCloseModal = useCallback(
|
const handleCloseModal = useCallback(() => state.current && closeModal(state.current.id), [closeModal, state]);
|
||||||
() => state.current && closeModal(state.current.id),
|
|
||||||
[closeModal, state.current?.id],
|
|
||||||
);
|
|
||||||
|
|
||||||
const activeModals = state.modals.filter((modal) => modal.id === state.current?.id || modal.props.keepMounted);
|
const activeModals = state.modals.filter((modal) => modal.id === state.current?.id || modal.props.keepMounted);
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
"prettier": "@homarr/prettier-config",
|
"prettier": "@homarr/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@mantine/notifications": "^7.13.2",
|
"@mantine/notifications": "^7.13.3",
|
||||||
"@tabler/icons-react": "^3.19.0"
|
"@tabler/icons-react": "^3.19.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const optionMapping: OptionMapping = {
|
|||||||
},
|
},
|
||||||
"mediaRequests-requestStats": {},
|
"mediaRequests-requestStats": {},
|
||||||
calendar: {
|
calendar: {
|
||||||
|
releaseType: (oldOptions) => [oldOptions.radarrReleaseType],
|
||||||
filterFutureMonths: () => undefined,
|
filterFutureMonths: () => undefined,
|
||||||
filterPastMonths: () => undefined,
|
filterPastMonths: () => undefined,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,11 +30,11 @@
|
|||||||
"@homarr/modals-collection": "workspace:^0.1.0",
|
"@homarr/modals-collection": "workspace:^0.1.0",
|
||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@mantine/core": "^7.13.2",
|
"@mantine/core": "^7.13.3",
|
||||||
"@mantine/hooks": "^7.13.2",
|
"@mantine/hooks": "^7.13.3",
|
||||||
"@mantine/spotlight": "^7.13.2",
|
"@mantine/spotlight": "^7.13.3",
|
||||||
"@tabler/icons-react": "^3.19.0",
|
"@tabler/icons-react": "^3.19.0",
|
||||||
"jotai": "^2.10.0",
|
"jotai": "^2.10.1",
|
||||||
"next": "^14.2.15",
|
"next": "^14.2.15",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"use-deep-compare-effect": "^1.8.1"
|
"use-deep-compare-effect": "^1.8.1"
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const ChildrenActionItem = ({ childrenOptions, action, query }: ChildrenA
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Spotlight.Action renderRoot={renderRoot} onClick={onClick} className={classes.spotlightAction}>
|
<Spotlight.Action renderRoot={renderRoot} onClick={onClick} className={classes.spotlightAction}>
|
||||||
<action.component {...childrenOptions.option} />
|
<action.Component {...childrenOptions.option} />
|
||||||
</Spotlight.Action>
|
</Spotlight.Action>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
|
|||||||
closeSpotlightOnTrigger={interaction.type !== "mode" && interaction.type !== "children"}
|
closeSpotlightOnTrigger={interaction.type !== "mode" && interaction.type !== "children"}
|
||||||
className={classes.spotlightAction}
|
className={classes.spotlightAction}
|
||||||
>
|
>
|
||||||
<group.component {...option} />
|
<group.Component {...option} />
|
||||||
</Spotlight.Action>
|
</Spotlight.Action>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export const Spotlight = () => {
|
|||||||
|
|
||||||
{childrenOptions ? (
|
{childrenOptions ? (
|
||||||
<Group>
|
<Group>
|
||||||
<childrenOptions.detailComponent options={childrenOptions.option as never} />
|
<childrenOptions.DetailComponent options={childrenOptions.option as never} />
|
||||||
</Group>
|
</Group>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import type { ReactNode } from "react";
|
|||||||
import type { inferSearchInteractionDefinition } from "./interaction";
|
import type { inferSearchInteractionDefinition } from "./interaction";
|
||||||
|
|
||||||
export interface CreateChildrenOptionsProps<TParentOptions extends Record<string, unknown>> {
|
export interface CreateChildrenOptionsProps<TParentOptions extends Record<string, unknown>> {
|
||||||
detailComponent: ({ options }: { options: TParentOptions }) => ReactNode;
|
DetailComponent: ({ options }: { options: TParentOptions }) => ReactNode;
|
||||||
useActions: (options: TParentOptions, query: string) => ChildrenAction<TParentOptions>[];
|
useActions: (options: TParentOptions, query: string) => ChildrenAction<TParentOptions>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChildrenAction<TParentOptions extends Record<string, unknown>> {
|
export interface ChildrenAction<TParentOptions extends Record<string, unknown>> {
|
||||||
key: string;
|
key: string;
|
||||||
component: (option: TParentOptions) => JSX.Element;
|
Component: (option: TParentOptions) => JSX.Element;
|
||||||
useInteraction: (option: TParentOptions, query: string) => inferSearchInteractionDefinition<"link" | "javaScript">;
|
useInteraction: (option: TParentOptions, query: string) => inferSearchInteractionDefinition<"link" | "javaScript">;
|
||||||
hide?: boolean | ((option: TParentOptions) => boolean);
|
hide?: boolean | ((option: TParentOptions) => boolean);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ type CommonSearchGroup<TOption extends Record<string, unknown>, TOptionProps ext
|
|||||||
// key path is used to define the path to a unique key in the option object
|
// key path is used to define the path to a unique key in the option object
|
||||||
keyPath: keyof TOption;
|
keyPath: keyof TOption;
|
||||||
title: stringOrTranslation;
|
title: stringOrTranslation;
|
||||||
component: (option: TOption) => JSX.Element;
|
Component: (option: TOption) => JSX.Element;
|
||||||
useInteraction: (option: TOption, query: string) => inferSearchInteractionDefinition<SearchInteraction>;
|
useInteraction: (option: TOption, query: string) => inferSearchInteractionDefinition<SearchInteraction>;
|
||||||
onKeyDown?: (
|
onKeyDown?: (
|
||||||
event: KeyboardEvent,
|
event: KeyboardEvent,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const searchInteractions = [
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
useActions: CreateChildrenOptionsProps<any>["useActions"];
|
useActions: CreateChildrenOptionsProps<any>["useActions"];
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
detailComponent: CreateChildrenOptionsProps<any>["detailComponent"];
|
DetailComponent: CreateChildrenOptionsProps<any>["DetailComponent"];
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
option: any;
|
option: any;
|
||||||
}>(),
|
}>(),
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const appChildrenOptions = createChildrenOptions<App>({
|
|||||||
useActions: () => [
|
useActions: () => [
|
||||||
{
|
{
|
||||||
key: "open",
|
key: "open",
|
||||||
component: () => {
|
Component: () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -34,7 +34,7 @@ const appChildrenOptions = createChildrenOptions<App>({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "edit",
|
key: "edit",
|
||||||
component: () => {
|
Component: () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -47,7 +47,7 @@ const appChildrenOptions = createChildrenOptions<App>({
|
|||||||
useInteraction: interaction.link(({ id }) => ({ href: `/manage/apps/edit/${id}` })),
|
useInteraction: interaction.link(({ id }) => ({ href: `/manage/apps/edit/${id}` })),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
detailComponent: ({ options }) => {
|
DetailComponent: ({ options }) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -75,7 +75,7 @@ const appChildrenOptions = createChildrenOptions<App>({
|
|||||||
export const appsSearchGroup = createGroup<App>({
|
export const appsSearchGroup = createGroup<App>({
|
||||||
keyPath: "id",
|
keyPath: "id",
|
||||||
title: (t) => t("search.mode.appIntegrationBoard.group.app.title"),
|
title: (t) => t("search.mode.appIntegrationBoard.group.app.title"),
|
||||||
component: (app) => (
|
Component: (app) => (
|
||||||
<Group px="md" py="sm">
|
<Group px="md" py="sm">
|
||||||
<Avatar
|
<Avatar
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const boardChildrenOptions = createChildrenOptions<Board>({
|
|||||||
const actions: (ChildrenAction<Board> & { hidden?: boolean })[] = [
|
const actions: (ChildrenAction<Board> & { hidden?: boolean })[] = [
|
||||||
{
|
{
|
||||||
key: "open",
|
key: "open",
|
||||||
component: () => {
|
Component: () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -37,7 +37,7 @@ const boardChildrenOptions = createChildrenOptions<Board>({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "homeBoard",
|
key: "homeBoard",
|
||||||
component: () => {
|
Component: () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -61,7 +61,7 @@ const boardChildrenOptions = createChildrenOptions<Board>({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "settings",
|
key: "settings",
|
||||||
component: () => {
|
Component: () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -78,7 +78,7 @@ const boardChildrenOptions = createChildrenOptions<Board>({
|
|||||||
|
|
||||||
return actions;
|
return actions;
|
||||||
},
|
},
|
||||||
detailComponent: ({ options: board }) => {
|
DetailComponent: ({ options: board }) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -102,7 +102,7 @@ const boardChildrenOptions = createChildrenOptions<Board>({
|
|||||||
export const boardsSearchGroup = createGroup<Board>({
|
export const boardsSearchGroup = createGroup<Board>({
|
||||||
keyPath: "id",
|
keyPath: "id",
|
||||||
title: "Boards",
|
title: "Boards",
|
||||||
component: (board) => (
|
Component: (board) => (
|
||||||
<Group px="md" py="sm">
|
<Group px="md" py="sm">
|
||||||
{board.logoImageUrl ? (
|
{board.logoImageUrl ? (
|
||||||
<img src={board.logoImageUrl} alt={board.name} width={24} height={24} />
|
<img src={board.logoImageUrl} alt={board.name} width={24} height={24} />
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { interaction } from "../../lib/interaction";
|
|||||||
export const integrationsSearchGroup = createGroup<{ id: string; kind: IntegrationKind; name: string }>({
|
export const integrationsSearchGroup = createGroup<{ id: string; kind: IntegrationKind; name: string }>({
|
||||||
keyPath: "id",
|
keyPath: "id",
|
||||||
title: (t) => t("search.mode.appIntegrationBoard.group.integration.title"),
|
title: (t) => t("search.mode.appIntegrationBoard.group.integration.title"),
|
||||||
component: (integration) => (
|
Component: (integration) => (
|
||||||
<Group px="md" py="sm">
|
<Group px="md" py="sm">
|
||||||
<IntegrationAvatar size="sm" kind={integration.kind} />
|
<IntegrationAvatar size="sm" kind={integration.kind} />
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export const languageChildrenOptions = createChildrenOptions<Record<string, unkn
|
|||||||
)
|
)
|
||||||
.map(({ localeKey, attributes }) => ({
|
.map(({ localeKey, attributes }) => ({
|
||||||
key: localeKey,
|
key: localeKey,
|
||||||
component() {
|
Component() {
|
||||||
return (
|
return (
|
||||||
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%">
|
<Group mx="md" my="sm" wrap="nowrap" justify="space-between" w="100%">
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap">
|
||||||
@@ -53,7 +53,7 @@ export const languageChildrenOptions = createChildrenOptions<Record<string, unkn
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
detailComponent: () => {
|
DetailComponent: () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const newIntegrationChildrenOptions = createChildrenOptions<Record<string
|
|||||||
)
|
)
|
||||||
.map(([kind, integrationDef]) => ({
|
.map(([kind, integrationDef]) => ({
|
||||||
key: kind,
|
key: kind,
|
||||||
component() {
|
Component() {
|
||||||
return (
|
return (
|
||||||
<Group mx="md" my="sm" wrap="nowrap" w="100%">
|
<Group mx="md" my="sm" wrap="nowrap" w="100%">
|
||||||
<IntegrationAvatar kind={kind} size="sm" />
|
<IntegrationAvatar kind={kind} size="sm" />
|
||||||
@@ -31,7 +31,7 @@ export const newIntegrationChildrenOptions = createChildrenOptions<Record<string
|
|||||||
useInteraction: interaction.link(() => ({ href: `/manage/integrations/new?kind=${kind}` })),
|
useInteraction: interaction.link(() => ({ href: `/manage/integrations/new?kind=${kind}` })),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
detailComponent() {
|
DetailComponent() {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export const commandMode = {
|
|||||||
keyPath: "commandKey",
|
keyPath: "commandKey",
|
||||||
title: "Global commands",
|
title: "Global commands",
|
||||||
useInteraction: (option, query) => option.useInteraction(option, query),
|
useInteraction: (option, query) => option.useInteraction(option, query),
|
||||||
component: ({ icon: Icon, name }) => (
|
Component: ({ icon: Icon, name }) => (
|
||||||
<Group px="md" py="sm">
|
<Group px="md" py="sm">
|
||||||
<Icon stroke={1.5} />
|
<Icon stroke={1.5} />
|
||||||
<Text>{name}</Text>
|
<Text>{name}</Text>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const searchEnginesChildrenOptions = createChildrenOptions<SearchEngine>(
|
|||||||
useActions: () => [
|
useActions: () => [
|
||||||
{
|
{
|
||||||
key: "search",
|
key: "search",
|
||||||
component: ({ name }) => {
|
Component: ({ name }) => {
|
||||||
const tChildren = useScopedI18n("search.mode.external.group.searchEngine.children");
|
const tChildren = useScopedI18n("search.mode.external.group.searchEngine.children");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -30,7 +30,7 @@ export const searchEnginesChildrenOptions = createChildrenOptions<SearchEngine>(
|
|||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
detailComponent({ options }) {
|
DetailComponent({ options }) {
|
||||||
const tChildren = useScopedI18n("search.mode.external.group.searchEngine.children");
|
const tChildren = useScopedI18n("search.mode.external.group.searchEngine.children");
|
||||||
return (
|
return (
|
||||||
<Stack mx="md" my="sm">
|
<Stack mx="md" my="sm">
|
||||||
@@ -47,7 +47,7 @@ export const searchEnginesChildrenOptions = createChildrenOptions<SearchEngine>(
|
|||||||
export const searchEnginesSearchGroups = createGroup<SearchEngine>({
|
export const searchEnginesSearchGroups = createGroup<SearchEngine>({
|
||||||
keyPath: "short",
|
keyPath: "short",
|
||||||
title: (t) => t("search.mode.external.group.searchEngine.title"),
|
title: (t) => t("search.mode.external.group.searchEngine.title"),
|
||||||
component: ({ iconUrl, name, short, description }) => {
|
Component: ({ iconUrl, name, short, description }) => {
|
||||||
return (
|
return (
|
||||||
<Group w="100%" wrap="nowrap" justify="space-between" align="center" px="md" py="xs">
|
<Group w="100%" wrap="nowrap" justify="space-between" align="center" px="md" py="xs">
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap">
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const helpMode = {
|
|||||||
keyPath: "character",
|
keyPath: "character",
|
||||||
title: (t) => t("search.mode.help.group.mode.title"),
|
title: (t) => t("search.mode.help.group.mode.title"),
|
||||||
options: searchModesWithoutHelp.map(({ character, modeKey }) => ({ character, modeKey })),
|
options: searchModesWithoutHelp.map(({ character, modeKey }) => ({ character, modeKey })),
|
||||||
component: ({ modeKey, character }) => {
|
Component: ({ modeKey, character }) => {
|
||||||
const t = useScopedI18n(`search.mode.${modeKey}`);
|
const t = useScopedI18n(`search.mode.${modeKey}`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -59,7 +59,7 @@ const helpMode = {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
component: (props) => (
|
Component: (props) => (
|
||||||
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center">
|
<Group px="md" py="xs" w="100%" wrap="nowrap" align="center">
|
||||||
<props.icon />
|
<props.icon />
|
||||||
<Text>{props.label}</Text>
|
<Text>{props.label}</Text>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const pagesSearchGroup = createGroup<{
|
|||||||
}>({
|
}>({
|
||||||
keyPath: "path",
|
keyPath: "path",
|
||||||
title: (t) => t("search.mode.page.group.page.title"),
|
title: (t) => t("search.mode.page.group.page.title"),
|
||||||
component: ({ name, icon: Icon }) => (
|
Component: ({ name, icon: Icon }) => (
|
||||||
<Group px="md" py="sm">
|
<Group px="md" py="sm">
|
||||||
<Icon stroke={1.5} />
|
<Icon stroke={1.5} />
|
||||||
<Text>{name}</Text>
|
<Text>{name}</Text>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const groupChildrenOptions = createChildrenOptions<Group>({
|
|||||||
useActions: () => [
|
useActions: () => [
|
||||||
{
|
{
|
||||||
key: "detail",
|
key: "detail",
|
||||||
component: () => {
|
Component: () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
return (
|
return (
|
||||||
<Group mx="md" my="sm">
|
<Group mx="md" my="sm">
|
||||||
@@ -29,7 +29,7 @@ const groupChildrenOptions = createChildrenOptions<Group>({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "manageMember",
|
key: "manageMember",
|
||||||
component: () => {
|
Component: () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
return (
|
return (
|
||||||
<Group mx="md" my="sm">
|
<Group mx="md" my="sm">
|
||||||
@@ -42,7 +42,7 @@ const groupChildrenOptions = createChildrenOptions<Group>({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "managePermission",
|
key: "managePermission",
|
||||||
component: () => {
|
Component: () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
return (
|
return (
|
||||||
<Group mx="md" my="sm">
|
<Group mx="md" my="sm">
|
||||||
@@ -54,7 +54,7 @@ const groupChildrenOptions = createChildrenOptions<Group>({
|
|||||||
useInteraction: interaction.link(({ id }) => ({ href: `/manage/users/groups/${id}/permissions` })),
|
useInteraction: interaction.link(({ id }) => ({ href: `/manage/users/groups/${id}/permissions` })),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
detailComponent: ({ options }) => {
|
DetailComponent: ({ options }) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
return (
|
return (
|
||||||
<Stack mx="md" my="sm">
|
<Stack mx="md" my="sm">
|
||||||
@@ -71,7 +71,7 @@ const groupChildrenOptions = createChildrenOptions<Group>({
|
|||||||
export const groupsSearchGroup = createGroup<Group>({
|
export const groupsSearchGroup = createGroup<Group>({
|
||||||
keyPath: "id",
|
keyPath: "id",
|
||||||
title: "Groups",
|
title: "Groups",
|
||||||
component: ({ name }) => (
|
Component: ({ name }) => (
|
||||||
<Group px="md" py="sm">
|
<Group px="md" py="sm">
|
||||||
<Text>{name}</Text>
|
<Text>{name}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const userChildrenOptions = createChildrenOptions<User>({
|
|||||||
useActions: () => [
|
useActions: () => [
|
||||||
{
|
{
|
||||||
key: "detail",
|
key: "detail",
|
||||||
component: () => {
|
Component: () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -30,7 +30,7 @@ const userChildrenOptions = createChildrenOptions<User>({
|
|||||||
useInteraction: interaction.link(({ id }) => ({ href: `/manage/users/${id}/general` })),
|
useInteraction: interaction.link(({ id }) => ({ href: `/manage/users/${id}/general` })),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
detailComponent: ({ options }) => {
|
DetailComponent: ({ options }) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -49,7 +49,7 @@ const userChildrenOptions = createChildrenOptions<User>({
|
|||||||
export const usersSearchGroup = createGroup<User>({
|
export const usersSearchGroup = createGroup<User>({
|
||||||
keyPath: "id",
|
keyPath: "id",
|
||||||
title: (t) => t("search.mode.userGroup.group.user.title"),
|
title: (t) => t("search.mode.userGroup.group.user.title"),
|
||||||
component: (user) => (
|
Component: (user) => (
|
||||||
<Group px="md" py="sm">
|
<Group px="md" py="sm">
|
||||||
<UserAvatar user={user} size="sm" />
|
<UserAvatar user={user} size="sm" />
|
||||||
<Text>{user.name}</Text>
|
<Text>{user.name}</Text>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
"prettier": "@homarr/prettier-config",
|
"prettier": "@homarr/prettier-config",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"mantine-react-table": "2.0.0-beta.6",
|
"mantine-react-table": "2.0.0-beta.7",
|
||||||
"next-international": "^1.2.4"
|
"next-international": "^1.2.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ export default {
|
|||||||
homeBoard: {
|
homeBoard: {
|
||||||
label: "Home board",
|
label: "Home board",
|
||||||
},
|
},
|
||||||
|
pingIconsEnabled: {
|
||||||
|
label: "Use icons for pings",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
usernameTaken: "Username already taken",
|
usernameTaken: "Username already taken",
|
||||||
@@ -116,6 +119,16 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
changePingIconsEnabled: {
|
||||||
|
notification: {
|
||||||
|
success: {
|
||||||
|
message: "Ping icons toggled successfully",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
message: "Unable to toggle ping icons",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
manageAvatar: {
|
manageAvatar: {
|
||||||
changeImage: {
|
changeImage: {
|
||||||
label: "Change image",
|
label: "Change image",
|
||||||
@@ -563,6 +576,7 @@ export default {
|
|||||||
tryAgain: "Try again",
|
tryAgain: "Try again",
|
||||||
loading: "Loading",
|
loading: "Loading",
|
||||||
},
|
},
|
||||||
|
here: "here",
|
||||||
iconPicker: {
|
iconPicker: {
|
||||||
label: "Icon URL",
|
label: "Icon URL",
|
||||||
header: "Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you.",
|
header: "Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you.",
|
||||||
@@ -570,6 +584,9 @@ export default {
|
|||||||
information: {
|
information: {
|
||||||
min: "Min",
|
min: "Min",
|
||||||
max: "Max",
|
max: "Max",
|
||||||
|
days: "Days",
|
||||||
|
hours: "Hours",
|
||||||
|
minutes: "Minutes",
|
||||||
},
|
},
|
||||||
notification: {
|
notification: {
|
||||||
create: {
|
create: {
|
||||||
@@ -1018,6 +1035,14 @@ export default {
|
|||||||
name: "Calendar",
|
name: "Calendar",
|
||||||
description: "Display events from your integrations in a calendar view within a certain relative time period",
|
description: "Display events from your integrations in a calendar view within a certain relative time period",
|
||||||
option: {
|
option: {
|
||||||
|
releaseType: {
|
||||||
|
label: "Radarr release type",
|
||||||
|
options: {
|
||||||
|
inCinemas: "In cinemas",
|
||||||
|
digitalRelease: "Digital release",
|
||||||
|
physicalRelease: "Physical release",
|
||||||
|
},
|
||||||
|
},
|
||||||
filterPastMonths: {
|
filterPastMonths: {
|
||||||
label: "Start from",
|
label: "Start from",
|
||||||
},
|
},
|
||||||
@@ -1097,16 +1122,17 @@ export default {
|
|||||||
},
|
},
|
||||||
popover: {
|
popover: {
|
||||||
information: "Information",
|
information: "Information",
|
||||||
processor: "Processor:",
|
processor: "Processor: {cpuModelName}",
|
||||||
memory: "Memory:",
|
memory: "Memory: {memory}GiB",
|
||||||
version: "Version:",
|
memoryAvailable: "Available: {memoryAvailable}GiB ({percent}%)",
|
||||||
uptime: "Uptime: {days} days, {hours} hours",
|
version: "Version: {version}",
|
||||||
|
uptime: "Uptime: {days} Days, {hours} Hours, {minutes} Minutes",
|
||||||
loadAverage: "Load average:",
|
loadAverage: "Load average:",
|
||||||
minute: "1 minute:",
|
minute: "1 minute",
|
||||||
minutes: "{count} minutes:",
|
minutes: "{count} minutes",
|
||||||
used: "Used",
|
used: "Used",
|
||||||
diskAvailable: "Available",
|
available: "Available",
|
||||||
memAvailable: "Available:",
|
lastSeen: "Last status update: {lastSeen}",
|
||||||
},
|
},
|
||||||
memory: {},
|
memory: {},
|
||||||
error: {
|
error: {
|
||||||
@@ -1136,6 +1162,14 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
integration: {
|
||||||
|
noData: "No integration found",
|
||||||
|
description: "Click {here} to create a new integration",
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
noData: "No app found",
|
||||||
|
description: "Click {here} to create a new app",
|
||||||
|
},
|
||||||
error: {
|
error: {
|
||||||
action: {
|
action: {
|
||||||
logs: "Check logs for more details",
|
logs: "Check logs for more details",
|
||||||
@@ -1703,6 +1737,7 @@ export default {
|
|||||||
language: "Language & Region",
|
language: "Language & Region",
|
||||||
board: "Home board",
|
board: "Home board",
|
||||||
firstDayOfWeek: "First day of the week",
|
firstDayOfWeek: "First day of the week",
|
||||||
|
accessibility: "Accessibility",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
security: {
|
security: {
|
||||||
|
|||||||
@@ -28,11 +28,11 @@
|
|||||||
"@homarr/log": "workspace:^0.1.0",
|
"@homarr/log": "workspace:^0.1.0",
|
||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@mantine/core": "^7.13.2",
|
"@mantine/core": "^7.13.3",
|
||||||
"@mantine/dates": "^7.13.2",
|
"@mantine/dates": "^7.13.3",
|
||||||
"@mantine/hooks": "^7.13.2",
|
"@mantine/hooks": "^7.13.3",
|
||||||
"@tabler/icons-react": "^3.19.0",
|
"@tabler/icons-react": "^3.19.0",
|
||||||
"mantine-react-table": "2.0.0-beta.6",
|
"mantine-react-table": "2.0.0-beta.7",
|
||||||
"next": "^14.2.15",
|
"next": "^14.2.15",
|
||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export const TablePagination = ({ total }: TablePaginationProps) => {
|
|||||||
(control: ControlType) => {
|
(control: ControlType) => {
|
||||||
return getItemProps(calculatePageFor(control, current, total));
|
return getItemProps(calculatePageFor(control, current, total));
|
||||||
},
|
},
|
||||||
[current],
|
[current, getItemProps, total],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
@@ -43,7 +43,7 @@ export const TablePagination = ({ total }: TablePaginationProps) => {
|
|||||||
params.set("page", page.toString());
|
params.set("page", page.toString());
|
||||||
replace(`${pathName}?${params.toString()}`);
|
replace(`${pathName}?${params.toString()}`);
|
||||||
},
|
},
|
||||||
[pathName, searchParams],
|
[pathName, replace, searchParams],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -108,6 +108,10 @@ const firstDayOfWeekSchema = z.object({
|
|||||||
firstDayOfWeek: z.custom<DayOfWeek>((value) => z.number().min(0).max(6).safeParse(value).success),
|
firstDayOfWeek: z.custom<DayOfWeek>((value) => z.number().min(0).max(6).safeParse(value).success),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const pingIconsEnabledSchema = z.object({
|
||||||
|
pingIconsEnabled: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
export const userSchemas = {
|
export const userSchemas = {
|
||||||
signIn: signInSchema,
|
signIn: signInSchema,
|
||||||
registration: registrationSchema,
|
registration: registrationSchema,
|
||||||
@@ -121,4 +125,5 @@ export const userSchemas = {
|
|||||||
changePasswordApi: changePasswordApiSchema,
|
changePasswordApi: changePasswordApiSchema,
|
||||||
changeColorScheme: changeColorSchemeSchema,
|
changeColorScheme: changeColorSchemeSchema,
|
||||||
firstDayOfWeek: firstDayOfWeekSchema,
|
firstDayOfWeek: firstDayOfWeekSchema,
|
||||||
|
pingIconsEnabled: pingIconsEnabledSchema,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -38,8 +38,8 @@
|
|||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@mantine/core": "^7.13.2",
|
"@mantine/core": "^7.13.3",
|
||||||
"@mantine/hooks": "^7.13.2",
|
"@mantine/hooks": "^7.13.3",
|
||||||
"@tabler/icons-react": "^3.19.0",
|
"@tabler/icons-react": "^3.19.0",
|
||||||
"@tiptap/extension-color": "2.8.0",
|
"@tiptap/extension-color": "2.8.0",
|
||||||
"@tiptap/extension-highlight": "2.8.0",
|
"@tiptap/extension-highlight": "2.8.0",
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
"@tiptap/starter-kit": "^2.8.0",
|
"@tiptap/starter-kit": "^2.8.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"mantine-react-table": "2.0.0-beta.6",
|
"mantine-react-table": "2.0.0-beta.7",
|
||||||
"next": "^14.2.15",
|
"next": "^14.2.15",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"video.js": "^8.18.1"
|
"video.js": "^8.18.1"
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { memo, useMemo } from "react";
|
import { memo, useMemo } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import type { SelectProps } from "@mantine/core";
|
import type { SelectProps } from "@mantine/core";
|
||||||
import { Group, Loader, Select } from "@mantine/core";
|
import { Anchor, Group, Loader, Select, Text } from "@mantine/core";
|
||||||
import { IconCheck } from "@tabler/icons-react";
|
import { IconCheck } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import type { CommonWidgetInputProps } from "./common";
|
import type { CommonWidgetInputProps } from "./common";
|
||||||
import { useWidgetInputTranslation } from "./common";
|
import { useWidgetInputTranslation } from "./common";
|
||||||
import { useFormContext } from "./form";
|
import { useFormContext } from "./form";
|
||||||
|
|
||||||
export const WidgetAppInput = ({ property, kind, options }: CommonWidgetInputProps<"app">) => {
|
export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">) => {
|
||||||
const t = useWidgetInputTranslation(kind, property);
|
const t = useI18n();
|
||||||
|
const tInput = useWidgetInputTranslation(kind, property);
|
||||||
const form = useFormContext();
|
const form = useFormContext();
|
||||||
const { data: apps, isPending } = clientApi.app.selectable.useQuery();
|
const { data: apps, isPending } = clientApi.app.selectable.useQuery();
|
||||||
|
|
||||||
@@ -24,10 +27,11 @@ export const WidgetAppInput = ({ property, kind, options }: CommonWidgetInputPro
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
label={t("label")}
|
label={tInput("label")}
|
||||||
searchable
|
searchable
|
||||||
limit={10}
|
limit={10}
|
||||||
leftSection={<MemoizedLeftSection isPending={isPending} currentApp={currentApp} />}
|
leftSection={<MemoizedLeftSection isPending={isPending} currentApp={currentApp} />}
|
||||||
|
nothingFoundMessage={t("widget.common.app.noData")}
|
||||||
renderOption={renderSelectOption}
|
renderOption={renderSelectOption}
|
||||||
data={
|
data={
|
||||||
apps?.map((app) => ({
|
apps?.map((app) => ({
|
||||||
@@ -36,7 +40,18 @@ export const WidgetAppInput = ({ property, kind, options }: CommonWidgetInputPro
|
|||||||
iconUrl: app.iconUrl,
|
iconUrl: app.iconUrl,
|
||||||
})) ?? []
|
})) ?? []
|
||||||
}
|
}
|
||||||
description={options.withDescription ? t("description") : undefined}
|
inputWrapperOrder={["label", "input", "description", "error"]}
|
||||||
|
description={
|
||||||
|
<Text size="xs">
|
||||||
|
{t("widget.common.app.description", {
|
||||||
|
here: (
|
||||||
|
<Anchor size="xs" component={Link} target="_blank" href="/manage/apps/new">
|
||||||
|
{t("common.here")}
|
||||||
|
</Anchor>
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
{...form.getInputProps(`options.${property}`)}
|
{...form.getInputProps(`options.${property}`)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export const WidgetLocationInput = ({ property, kind }: CommonWidgetInputProps<"
|
|||||||
form.clearFieldError(`options.${property}.latitude`);
|
form.clearFieldError(`options.${property}.latitude`);
|
||||||
form.clearFieldError(`options.${property}.longitude`);
|
form.clearFieldError(`options.${property}.longitude`);
|
||||||
},
|
},
|
||||||
[handleChange],
|
[form, handleChange, property],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onSearch = useCallback(() => {
|
const onSearch = useCallback(() => {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export const WidgetMultiTextInput = ({ property, kind, options }: CommonWidgetIn
|
|||||||
success: validationResult.success,
|
success: validationResult.success,
|
||||||
result: validationResult,
|
result: validationResult,
|
||||||
};
|
};
|
||||||
}, [search]);
|
}, [options.validate, search]);
|
||||||
|
|
||||||
const error = React.useMemo(() => {
|
const error = React.useMemo(() => {
|
||||||
/* hide the error when nothing is being typed since "" is not valid but is not an explicit error */
|
/* hide the error when nothing is being typed since "" is not valid but is not an explicit error */
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { Flex, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
import { Flex, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
||||||
|
import { IconLoader } from "@tabler/icons-react";
|
||||||
import combineClasses from "clsx";
|
import combineClasses from "clsx";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
@@ -59,7 +60,7 @@ export default function AppWidget({ options, isEditMode }: WidgetComponentProps<
|
|||||||
</Flex>
|
</Flex>
|
||||||
</Tooltip.Floating>
|
</Tooltip.Floating>
|
||||||
{options.pingEnabled && app.href ? (
|
{options.pingEnabled && app.href ? (
|
||||||
<Suspense fallback={<PingDot color="blue" tooltip={`${t("common.action.loading")}…`} />}>
|
<Suspense fallback={<PingDot icon={IconLoader} color="blue" tooltip={`${t("common.action.loading")}…`} />}>
|
||||||
<PingIndicator href={app.href} />
|
<PingIndicator href={app.href} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { IconApps, IconDeviceDesktopX } from "@tabler/icons-react";
|
|||||||
import { createWidgetDefinition } from "../definition";
|
import { createWidgetDefinition } from "../definition";
|
||||||
import { optionsBuilder } from "../options";
|
import { optionsBuilder } from "../options";
|
||||||
|
|
||||||
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("app", {
|
export const { definition, componentLoader } = createWidgetDefinition("app", {
|
||||||
icon: IconApps,
|
icon: IconApps,
|
||||||
options: optionsBuilder.from((factory) => ({
|
options: optionsBuilder.from((factory) => ({
|
||||||
appId: factory.app(),
|
appId: factory.app(),
|
||||||
|
|||||||
@@ -1,23 +1,33 @@
|
|||||||
import type { MantineColor } from "@mantine/core";
|
import type { MantineColor } from "@mantine/core";
|
||||||
import { Box, Tooltip } from "@mantine/core";
|
import { Box, Tooltip } from "@mantine/core";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import type { TablerIcon } from "@homarr/ui";
|
||||||
|
|
||||||
interface PingDotProps {
|
interface PingDotProps {
|
||||||
|
icon: TablerIcon;
|
||||||
color: MantineColor;
|
color: MantineColor;
|
||||||
tooltip: string;
|
tooltip: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PingDot = ({ color, tooltip }: PingDotProps) => {
|
export const PingDot = ({ color, tooltip, ...props }: PingDotProps) => {
|
||||||
|
const [pingIconsEnabled] = clientApi.user.getPingIconsEnabledOrDefault.useSuspenseQuery();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box bottom="2.5cqmin" right="2.5cqmin" pos="absolute">
|
<Box bottom="2.5cqmin" right="2.5cqmin" pos="absolute">
|
||||||
<Tooltip label={tooltip}>
|
<Tooltip label={tooltip}>
|
||||||
<Box
|
{pingIconsEnabled ? (
|
||||||
bg={color}
|
<props.icon style={{ width: "10cqmin", height: "10cqmin" }} color={color} />
|
||||||
style={{
|
) : (
|
||||||
borderRadius: "100%",
|
<Box
|
||||||
}}
|
bg={color}
|
||||||
w="10cqmin"
|
style={{
|
||||||
h="10cqmin"
|
borderRadius: "100%",
|
||||||
></Box>
|
}}
|
||||||
|
w="10cqmin"
|
||||||
|
h="10cqmin"
|
||||||
|
></Box>
|
||||||
|
)}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { IconCheck, IconX } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
@@ -32,9 +33,12 @@ export const PingIndicator = ({ href }: PingIndicatorProps) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isError = "error" in pingResult || pingResult.statusCode >= 500;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PingDot
|
<PingDot
|
||||||
color={"error" in pingResult || pingResult.statusCode >= 500 ? "red" : "green"}
|
icon={isError ? IconX : IconCheck}
|
||||||
|
color={isError ? "red" : "green"}
|
||||||
tooltip={"statusCode" in pingResult ? pingResult.statusCode.toString() : pingResult.error}
|
tooltip={"statusCode" in pingResult ? pingResult.statusCode.toString() : pingResult.error}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { IconClock } from "@tabler/icons-react";
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
import type { CalendarEvent } from "@homarr/integrations/types";
|
import type { CalendarEvent } from "@homarr/integrations/types";
|
||||||
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import classes from "./calendar-event-list.module.css";
|
import classes from "./calendar-event-list.module.css";
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ interface CalendarEventListProps {
|
|||||||
|
|
||||||
export const CalendarEventList = ({ events }: CalendarEventListProps) => {
|
export const CalendarEventList = ({ events }: CalendarEventListProps) => {
|
||||||
const { colorScheme } = useMantineColorScheme();
|
const { colorScheme } = useMantineColorScheme();
|
||||||
|
const t = useI18n();
|
||||||
return (
|
return (
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
offsetScrollbars
|
offsetScrollbars
|
||||||
@@ -57,14 +59,24 @@ export const CalendarEventList = ({ events }: CalendarEventListProps) => {
|
|||||||
{event.subName}
|
{event.subName}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Text fw={"bold"} lineClamp={1}>
|
<Text fw={"bold"} lineClamp={1} size="sm">
|
||||||
{event.name}
|
{event.name}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Group gap={3} wrap="nowrap">
|
{event.dates ? (
|
||||||
<IconClock opacity={0.7} size={"1rem"} />
|
<Group wrap="nowrap">
|
||||||
<Text c={"dimmed"}>{dayjs(event.date.toString()).format("HH:mm")}</Text>
|
<Text c="dimmed" size="sm">
|
||||||
</Group>
|
{t(
|
||||||
|
`widget.calendar.option.releaseType.options.${event.dates.find(({ date }) => event.date === date)?.type ?? "inCinemas"}`,
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<Group gap={3} wrap="nowrap">
|
||||||
|
<IconClock opacity={0.7} size={"1rem"} />
|
||||||
|
<Text c={"dimmed"}>{dayjs(event.date).format("HH:mm")}</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
{event.description && (
|
{event.description && (
|
||||||
<Text size={"xs"} c={"dimmed"} lineClamp={2}>
|
<Text size={"xs"} c={"dimmed"} lineClamp={2}>
|
||||||
|
|||||||
@@ -6,12 +6,31 @@ import { Calendar } from "@mantine/dates";
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import type { CalendarEvent } from "@homarr/integrations/types";
|
||||||
|
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { CalendarDay } from "./calender-day";
|
import { CalendarDay } from "./calender-day";
|
||||||
import classes from "./component.module.css";
|
import classes from "./component.module.css";
|
||||||
|
|
||||||
export default function CalendarWidget({ isEditMode, serverData }: WidgetComponentProps<"calendar">) {
|
export default function CalendarWidget({
|
||||||
|
isEditMode,
|
||||||
|
integrationIds,
|
||||||
|
itemId,
|
||||||
|
options,
|
||||||
|
}: WidgetComponentProps<"calendar">) {
|
||||||
|
const [events] = clientApi.widget.calendar.findAllEvents.useSuspenseQuery(
|
||||||
|
{
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
itemId: itemId!,
|
||||||
|
integrationIds,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
const [month, setMonth] = useState(new Date());
|
const [month, setMonth] = useState(new Date());
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const locale = params.locale as string;
|
const locale = params.locale as string;
|
||||||
@@ -67,9 +86,16 @@ export default function CalendarWidget({ isEditMode, serverData }: WidgetCompone
|
|||||||
padding: 0,
|
padding: 0,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
renderDay={(date) => {
|
renderDay={(tileDate) => {
|
||||||
const eventsForDate = (serverData?.initialData ?? []).filter((event) => dayjs(event.date).isSame(date, "day"));
|
const eventsForDate = events
|
||||||
return <CalendarDay date={date} events={eventsForDate} disabled={isEditMode} />;
|
.map((event) => ({
|
||||||
|
...event,
|
||||||
|
date: (event.dates?.filter(({ type }) => options.releaseType.includes(type)) ?? [event]).find(({ date }) =>
|
||||||
|
dayjs(date).isSame(tileDate, "day"),
|
||||||
|
)?.date,
|
||||||
|
}))
|
||||||
|
.filter((event): event is CalendarEvent => Boolean(event.date));
|
||||||
|
return <CalendarDay date={tileDate} events={eventsForDate} disabled={isEditMode} />;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
import { IconCalendar } from "@tabler/icons-react";
|
import { IconCalendar } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||||
|
import { radarrReleaseTypes } from "@homarr/integrations/types";
|
||||||
import { z } from "@homarr/validation";
|
import { z } from "@homarr/validation";
|
||||||
|
|
||||||
import { createWidgetDefinition } from "../definition";
|
import { createWidgetDefinition } from "../definition";
|
||||||
import { optionsBuilder } from "../options";
|
import { optionsBuilder } from "../options";
|
||||||
|
|
||||||
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("calendar", {
|
export const { definition, componentLoader } = createWidgetDefinition("calendar", {
|
||||||
icon: IconCalendar,
|
icon: IconCalendar,
|
||||||
options: optionsBuilder.from((factory) => ({
|
options: optionsBuilder.from((factory) => ({
|
||||||
|
releaseType: factory.multiSelect({
|
||||||
|
defaultValue: ["inCinemas", "digitalRelease"],
|
||||||
|
options: radarrReleaseTypes.map((value) => ({
|
||||||
|
value,
|
||||||
|
label: (t) => t(`widget.calendar.option.releaseType.options.${value}`),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
filterPastMonths: factory.number({
|
filterPastMonths: factory.number({
|
||||||
validate: z.number().min(2).max(9999),
|
validate: z.number().min(2).max(9999),
|
||||||
defaultValue: 2,
|
defaultValue: 2,
|
||||||
@@ -19,6 +27,4 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
|
|||||||
}),
|
}),
|
||||||
})),
|
})),
|
||||||
supportedIntegrations: getIntegrationKindsByCategory("calendar"),
|
supportedIntegrations: getIntegrationKindsByCategory("calendar"),
|
||||||
})
|
}).withDynamicImport(() => import("./component"));
|
||||||
.withServerData(() => import("./serverData"))
|
|
||||||
.withDynamicImport(() => import("./component"));
|
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
|
||||||
import { api } from "@homarr/api/server";
|
|
||||||
|
|
||||||
import type { WidgetProps } from "../definition";
|
|
||||||
|
|
||||||
export default async function getServerDataAsync({ integrationIds, itemId }: WidgetProps<"calendar">) {
|
|
||||||
if (!itemId) {
|
|
||||||
return {
|
|
||||||
initialData: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const data = await api.widget.calendar.findAllEvents({
|
|
||||||
integrationIds,
|
|
||||||
itemId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
initialData: data
|
|
||||||
.filter(
|
|
||||||
(
|
|
||||||
item,
|
|
||||||
): item is Exclude<Exclude<RouterOutputs["widget"]["calendar"]["findAllEvents"][number], null>, undefined> =>
|
|
||||||
item !== null,
|
|
||||||
)
|
|
||||||
.flatMap((item) => item.data),
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return {
|
|
||||||
initialData: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,57 +8,22 @@ import type { TablerIcon } from "@homarr/ui";
|
|||||||
import type { WidgetImports } from ".";
|
import type { WidgetImports } from ".";
|
||||||
import type { inferOptionsFromDefinition, WidgetOptionsRecord } from "./options";
|
import type { inferOptionsFromDefinition, WidgetOptionsRecord } from "./options";
|
||||||
|
|
||||||
type ServerDataLoader<TKind extends WidgetKind> = () => Promise<{
|
|
||||||
default: (props: WidgetProps<TKind>) => Promise<Record<string, unknown>>;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
const createWithDynamicImport =
|
const createWithDynamicImport =
|
||||||
<
|
|
||||||
TKind extends WidgetKind,
|
|
||||||
TDefinition extends WidgetDefinition,
|
|
||||||
TServerDataLoader extends ServerDataLoader<TKind> | undefined,
|
|
||||||
>(
|
|
||||||
kind: TKind,
|
|
||||||
definition: TDefinition,
|
|
||||||
serverDataLoader: TServerDataLoader,
|
|
||||||
) =>
|
|
||||||
(
|
|
||||||
componentLoader: () => LoaderComponent<
|
|
||||||
WidgetComponentProps<TKind> &
|
|
||||||
(TServerDataLoader extends ServerDataLoader<TKind>
|
|
||||||
? {
|
|
||||||
serverData: Awaited<ReturnType<Awaited<ReturnType<TServerDataLoader>>["default"]>>;
|
|
||||||
}
|
|
||||||
: never)
|
|
||||||
>,
|
|
||||||
) => ({
|
|
||||||
definition: {
|
|
||||||
...definition,
|
|
||||||
kind,
|
|
||||||
},
|
|
||||||
kind,
|
|
||||||
serverDataLoader,
|
|
||||||
componentLoader,
|
|
||||||
});
|
|
||||||
|
|
||||||
const createWithServerData =
|
|
||||||
<TKind extends WidgetKind, TDefinition extends WidgetDefinition>(kind: TKind, definition: TDefinition) =>
|
<TKind extends WidgetKind, TDefinition extends WidgetDefinition>(kind: TKind, definition: TDefinition) =>
|
||||||
<TServerDataLoader extends ServerDataLoader<TKind>>(serverDataLoader: TServerDataLoader) => ({
|
(componentLoader: () => LoaderComponent<WidgetComponentProps<TKind>>) => ({
|
||||||
definition: {
|
definition: {
|
||||||
...definition,
|
...definition,
|
||||||
kind,
|
kind,
|
||||||
},
|
},
|
||||||
kind,
|
kind,
|
||||||
serverDataLoader,
|
componentLoader,
|
||||||
withDynamicImport: createWithDynamicImport(kind, definition, serverDataLoader),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createWidgetDefinition = <TKind extends WidgetKind, TDefinition extends WidgetDefinition>(
|
export const createWidgetDefinition = <TKind extends WidgetKind, TDefinition extends WidgetDefinition>(
|
||||||
kind: TKind,
|
kind: TKind,
|
||||||
definition: TDefinition,
|
definition: TDefinition,
|
||||||
) => ({
|
) => ({
|
||||||
withServerData: createWithServerData(kind, definition),
|
withDynamicImport: createWithDynamicImport(kind, definition),
|
||||||
withDynamicImport: createWithDynamicImport(kind, definition, undefined),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export interface WidgetDefinition {
|
export interface WidgetDefinition {
|
||||||
@@ -83,15 +48,7 @@ export interface WidgetProps<TKind extends WidgetKind> {
|
|||||||
itemId: string | undefined; // undefined when in preview mode
|
itemId: string | undefined; // undefined when in preview mode
|
||||||
}
|
}
|
||||||
|
|
||||||
type inferServerDataForKind<TKind extends WidgetKind> = WidgetImports[TKind] extends {
|
|
||||||
serverDataLoader: ServerDataLoader<TKind>;
|
|
||||||
}
|
|
||||||
? Awaited<ReturnType<Awaited<ReturnType<WidgetImports[TKind]["serverDataLoader"]>>["default"]>>
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind> & {
|
export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind> & {
|
||||||
serverData?: inferServerDataForKind<TKind>;
|
|
||||||
} & {
|
|
||||||
boardId: string | undefined; // undefined when in preview mode
|
boardId: string | undefined; // undefined when in preview mode
|
||||||
isEditMode: boolean;
|
isEditMode: boolean;
|
||||||
setOptions: ({
|
setOptions: ({
|
||||||
|
|||||||
@@ -39,16 +39,25 @@ export default function DnsHoleControlsWidget({
|
|||||||
options,
|
options,
|
||||||
integrationIds,
|
integrationIds,
|
||||||
isEditMode,
|
isEditMode,
|
||||||
serverData,
|
|
||||||
}: WidgetComponentProps<typeof widgetKind>) {
|
}: WidgetComponentProps<typeof widgetKind>) {
|
||||||
// DnsHole integrations with interaction permissions
|
// DnsHole integrations with interaction permissions
|
||||||
const integrationsWithInteractions = useIntegrationsWithInteractAccess()
|
const integrationsWithInteractions = useIntegrationsWithInteractAccess()
|
||||||
.map(({ id }) => id)
|
.map(({ id }) => id)
|
||||||
.filter((id) => integrationIds.includes(id));
|
.filter((id) => integrationIds.includes(id));
|
||||||
|
|
||||||
// Initial summaries, null summary means disconnected, undefined status means processing
|
const [summaries] = clientApi.widget.dnsHole.summary.useSuspenseQuery(
|
||||||
const [summaries, setSummaries] = useState(serverData?.initialData ?? []);
|
{
|
||||||
|
widgetKind: "dnsHoleControls",
|
||||||
|
integrationIds,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const utils = clientApi.useUtils();
|
||||||
// Subscribe to summary updates
|
// Subscribe to summary updates
|
||||||
clientApi.widget.dnsHole.subscribeToSummary.useSubscription(
|
clientApi.widget.dnsHole.subscribeToSummary.useSubscription(
|
||||||
{
|
{
|
||||||
@@ -57,8 +66,20 @@ export default function DnsHoleControlsWidget({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
onData: (data) => {
|
onData: (data) => {
|
||||||
setSummaries((prevSummaries) =>
|
utils.widget.dnsHole.summary.setData(
|
||||||
prevSummaries.map((summary) => (summary.integration.id === data.integration.id ? data : summary)),
|
{
|
||||||
|
widgetKind: "dnsHoleControls",
|
||||||
|
integrationIds,
|
||||||
|
},
|
||||||
|
(prevData) => {
|
||||||
|
if (!prevData) return undefined;
|
||||||
|
|
||||||
|
const newData = prevData.map((summary) =>
|
||||||
|
summary.integration.id === data.integration.id ? { ...summary, summary: data.summary } : summary,
|
||||||
|
);
|
||||||
|
|
||||||
|
return newData;
|
||||||
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -67,39 +88,77 @@ export default function DnsHoleControlsWidget({
|
|||||||
// Mutations for dnsHole state, set to undefined on click, and change again on settle
|
// Mutations for dnsHole state, set to undefined on click, and change again on settle
|
||||||
const { mutate: enableDns } = clientApi.widget.dnsHole.enable.useMutation({
|
const { mutate: enableDns } = clientApi.widget.dnsHole.enable.useMutation({
|
||||||
onSettled: (_, error, { integrationId }) => {
|
onSettled: (_, error, { integrationId }) => {
|
||||||
setSummaries((prevSummaries) =>
|
utils.widget.dnsHole.summary.setData(
|
||||||
prevSummaries.map((data) => ({
|
{
|
||||||
...data,
|
widgetKind: "dnsHoleControls",
|
||||||
summary:
|
integrationIds,
|
||||||
data.integration.id === integrationId && data.summary
|
},
|
||||||
? { ...data.summary, status: error ? "disabled" : "enabled" }
|
(prevData) => {
|
||||||
: data.summary,
|
if (!prevData) return [];
|
||||||
})),
|
|
||||||
|
return prevData.map((item) =>
|
||||||
|
item.integration.id === integrationId && item.summary
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
summary: {
|
||||||
|
...item.summary,
|
||||||
|
status: error ? "disabled" : "enabled",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: item,
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { mutate: disableDns } = clientApi.widget.dnsHole.disable.useMutation({
|
const { mutate: disableDns } = clientApi.widget.dnsHole.disable.useMutation({
|
||||||
onSettled: (_, error, { integrationId }) => {
|
onSettled: (_, error, { integrationId }) => {
|
||||||
setSummaries((prevSummaries) =>
|
utils.widget.dnsHole.summary.setData(
|
||||||
prevSummaries.map((data) => ({
|
{
|
||||||
...data,
|
widgetKind: "dnsHoleControls",
|
||||||
summary:
|
integrationIds,
|
||||||
data.integration.id === integrationId && data.summary
|
},
|
||||||
? { ...data.summary, status: error ? "enabled" : "disabled" }
|
(prevData) => {
|
||||||
: data.summary,
|
if (!prevData) return [];
|
||||||
})),
|
|
||||||
|
return prevData.map((item) =>
|
||||||
|
item.integration.id === integrationId && item.summary
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
summary: {
|
||||||
|
...item.summary,
|
||||||
|
status: error ? "enabled" : "disabled",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: item,
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const toggleDns = (integrationId: string) => {
|
const toggleDns = (integrationId: string) => {
|
||||||
const integrationStatus = summaries.find(({ integration }) => integration.id === integrationId);
|
const integrationStatus = summaries.find(({ integration }) => integration.id === integrationId);
|
||||||
if (!integrationStatus?.summary?.status) return;
|
if (!integrationStatus?.summary?.status) return;
|
||||||
setSummaries((prevSummaries) =>
|
utils.widget.dnsHole.summary.setData(
|
||||||
prevSummaries.map((data) => ({
|
{
|
||||||
...data,
|
widgetKind: "dnsHoleControls",
|
||||||
summary:
|
integrationIds,
|
||||||
data.integration.id === integrationId && data.summary ? { ...data.summary, status: undefined } : data.summary,
|
},
|
||||||
})),
|
(prevData) => {
|
||||||
|
if (!prevData) return [];
|
||||||
|
|
||||||
|
return prevData.map((item) =>
|
||||||
|
item.integration.id === integrationId && item.summary
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
summary: {
|
||||||
|
...item.summary,
|
||||||
|
status: undefined,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: item,
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
if (integrationStatus.summary.status === "enabled") {
|
if (integrationStatus.summary.status === "enabled") {
|
||||||
disableDns({ integrationId, duration: 0 });
|
disableDns({ integrationId, duration: 0 });
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { optionsBuilder } from "../../options";
|
|||||||
|
|
||||||
export const widgetKind = "dnsHoleControls";
|
export const widgetKind = "dnsHoleControls";
|
||||||
|
|
||||||
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition(widgetKind, {
|
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
|
||||||
icon: IconDeviceGamepad,
|
icon: IconDeviceGamepad,
|
||||||
options: optionsBuilder.from((factory) => ({
|
options: optionsBuilder.from((factory) => ({
|
||||||
showToggleAllButtons: factory.switch({
|
showToggleAllButtons: factory.switch({
|
||||||
@@ -21,6 +21,4 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
|
|||||||
message: (t) => t("widget.dnsHoleControls.error.internalServerError"),
|
message: (t) => t("widget.dnsHoleControls.error.internalServerError"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
}).withDynamicImport(() => import("./component"));
|
||||||
.withServerData(() => import("./serverData"))
|
|
||||||
.withDynamicImport(() => import("./component"));
|
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
|
||||||
|
|
||||||
import { widgetKind } from ".";
|
|
||||||
import type { WidgetProps } from "../../definition";
|
|
||||||
|
|
||||||
export default async function getServerDataAsync({ integrationIds }: WidgetProps<typeof widgetKind>) {
|
|
||||||
if (integrationIds.length === 0) {
|
|
||||||
return {
|
|
||||||
initialData: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const currentDns = await api.widget.dnsHole.summary({
|
|
||||||
widgetKind,
|
|
||||||
integrationIds,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
initialData: currentDns,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return {
|
|
||||||
initialData: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo } from "react";
|
||||||
import type { BoxProps } from "@mantine/core";
|
import type { BoxProps } from "@mantine/core";
|
||||||
import { Avatar, AvatarGroup, Box, Card, Flex, Stack, Text, Tooltip } from "@mantine/core";
|
import { Avatar, AvatarGroup, Box, Card, Flex, Stack, Text, Tooltip } from "@mantine/core";
|
||||||
import { useElementSize } from "@mantine/hooks";
|
import { useElementSize } from "@mantine/hooks";
|
||||||
@@ -20,12 +20,20 @@ import { widgetKind } from ".";
|
|||||||
import type { WidgetComponentProps, WidgetProps } from "../../definition";
|
import type { WidgetComponentProps, WidgetProps } from "../../definition";
|
||||||
import { NoIntegrationSelectedError } from "../../errors";
|
import { NoIntegrationSelectedError } from "../../errors";
|
||||||
|
|
||||||
export default function DnsHoleSummaryWidget({
|
export default function DnsHoleSummaryWidget({ options, integrationIds }: WidgetComponentProps<typeof widgetKind>) {
|
||||||
options,
|
const [summaries] = clientApi.widget.dnsHole.summary.useSuspenseQuery(
|
||||||
integrationIds,
|
{
|
||||||
serverData,
|
widgetKind,
|
||||||
}: WidgetComponentProps<typeof widgetKind>) {
|
integrationIds,
|
||||||
const [summaries, setSummaries] = useState(serverData?.initialData ?? []);
|
},
|
||||||
|
{
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const utils = clientApi.useUtils();
|
||||||
|
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|
||||||
@@ -36,8 +44,21 @@ export default function DnsHoleSummaryWidget({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
onData: (data) => {
|
onData: (data) => {
|
||||||
setSummaries((prevSummaries) =>
|
utils.widget.dnsHole.summary.setData(
|
||||||
prevSummaries.map((summary) => (summary.integration.id === data.integration.id ? data : summary)),
|
{
|
||||||
|
widgetKind,
|
||||||
|
integrationIds,
|
||||||
|
},
|
||||||
|
(prevData) => {
|
||||||
|
if (!prevData) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newData = prevData.map((item) =>
|
||||||
|
item.integration.id === data.integration.id ? { ...item, summary: data.summary } : item,
|
||||||
|
);
|
||||||
|
return newData;
|
||||||
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -46,17 +67,10 @@ export default function DnsHoleSummaryWidget({
|
|||||||
const data = useMemo(
|
const data = useMemo(
|
||||||
() =>
|
() =>
|
||||||
summaries
|
summaries
|
||||||
.filter(
|
.filter((pair) => Math.abs(dayjs(pair.timestamp).diff()) < 30000)
|
||||||
(
|
.flatMap(({ summary }) => summary)
|
||||||
pair,
|
.filter((summary) => summary !== null),
|
||||||
): pair is {
|
[summaries],
|
||||||
integration: typeof pair.integration;
|
|
||||||
timestamp: typeof pair.timestamp;
|
|
||||||
summary: DnsHoleSummary;
|
|
||||||
} => pair.summary !== null && Math.abs(dayjs(pair.timestamp).diff()) < 30000,
|
|
||||||
)
|
|
||||||
.flatMap(({ summary }) => summary),
|
|
||||||
[summaries, serverData],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (integrationIds.length === 0) {
|
if (integrationIds.length === 0) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { optionsBuilder } from "../../options";
|
|||||||
|
|
||||||
export const widgetKind = "dnsHoleSummary";
|
export const widgetKind = "dnsHoleSummary";
|
||||||
|
|
||||||
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition(widgetKind, {
|
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
|
||||||
icon: IconAd,
|
icon: IconAd,
|
||||||
options: optionsBuilder.from((factory) => ({
|
options: optionsBuilder.from((factory) => ({
|
||||||
usePiHoleColors: factory.switch({
|
usePiHoleColors: factory.switch({
|
||||||
@@ -28,6 +28,4 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
|
|||||||
message: (t) => t("widget.dnsHoleSummary.error.internalServerError"),
|
message: (t) => t("widget.dnsHoleSummary.error.internalServerError"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
}).withDynamicImport(() => import("./component"));
|
||||||
.withServerData(() => import("./serverData"))
|
|
||||||
.withDynamicImport(() => import("./component"));
|
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
|
||||||
|
|
||||||
import { widgetKind } from ".";
|
|
||||||
import type { WidgetProps } from "../../definition";
|
|
||||||
|
|
||||||
export default async function getServerDataAsync({ integrationIds }: WidgetProps<typeof widgetKind>) {
|
|
||||||
if (integrationIds.length === 0) {
|
|
||||||
return {
|
|
||||||
initialData: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const currentDns = await api.widget.dnsHole.summary({
|
|
||||||
widgetKind,
|
|
||||||
integrationIds,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
initialData: currentDns,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return {
|
|
||||||
initialData: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import "../widgets-common.css";
|
import "../widgets-common.css";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import type { MantineStyleProp } from "@mantine/core";
|
import type { MantineStyleProp } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useDisclosure, useListState, useTimeout } from "@mantine/hooks";
|
import { useDisclosure, useTimeout } from "@mantine/hooks";
|
||||||
import type { IconProps } from "@tabler/icons-react";
|
import type { IconProps } from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
@@ -40,9 +40,6 @@ import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
|
|||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
|
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
|
||||||
import { humanFileSize } from "@homarr/common";
|
import { humanFileSize } from "@homarr/common";
|
||||||
import type { Modify } from "@homarr/common/types";
|
|
||||||
import type { Integration } from "@homarr/db/schema/sqlite";
|
|
||||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
|
||||||
import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions";
|
import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||||
import type {
|
import type {
|
||||||
DownloadClientJobsAndStatus,
|
DownloadClientJobsAndStatus,
|
||||||
@@ -91,30 +88,35 @@ export default function DownloadClientsWidget({
|
|||||||
isEditMode,
|
isEditMode,
|
||||||
integrationIds,
|
integrationIds,
|
||||||
options,
|
options,
|
||||||
serverData,
|
|
||||||
setOptions,
|
setOptions,
|
||||||
}: WidgetComponentProps<"downloads">) {
|
}: WidgetComponentProps<"downloads">) {
|
||||||
const integrationsWithInteractions = useIntegrationsWithInteractAccess().flatMap(({ id }) =>
|
const integrationsWithInteractions = useIntegrationsWithInteractAccess().flatMap(({ id }) =>
|
||||||
integrationIds.includes(id) ? [id] : [],
|
integrationIds.includes(id) ? [id] : [],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [currentItems, currentItemsHandlers] = useListState<{
|
const [currentItems] = clientApi.widget.downloads.getJobsAndStatuses.useSuspenseQuery(
|
||||||
integration: Modify<Integration, { kind: IntegrationKindByCategory<"downloadClient"> }>;
|
{
|
||||||
timestamp: Date;
|
integrationIds,
|
||||||
data: DownloadClientJobsAndStatus | null;
|
},
|
||||||
}>(
|
{
|
||||||
//Automatically invalidate data older than 30 seconds
|
refetchOnMount: false,
|
||||||
serverData?.initialData?.map((item) =>
|
refetchOnWindowFocus: false,
|
||||||
dayjs().diff(item.timestamp) < invalidateTime ? item : { ...item, timestamp: new Date(0), data: null },
|
refetchOnReconnect: false,
|
||||||
) ?? [],
|
retry: false,
|
||||||
|
select(data) {
|
||||||
|
return data.map((item) =>
|
||||||
|
dayjs().diff(item.timestamp) < invalidateTime ? item : { ...item, timestamp: new Date(0), data: null },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
const utils = clientApi.useUtils();
|
||||||
|
|
||||||
//Invalidate all data after no update for 30 seconds using timer
|
//Invalidate all data after no update for 30 seconds using timer
|
||||||
const invalidationTimer = useTimeout(
|
const invalidationTimer = useTimeout(
|
||||||
() => {
|
() => {
|
||||||
currentItemsHandlers.applyWhere(
|
utils.widget.downloads.getJobsAndStatuses.setData({ integrationIds }, (prevData) =>
|
||||||
() => true,
|
prevData?.map((item) => ({ ...item, timestamp: new Date(0), data: null })),
|
||||||
(item) => ({ ...item, timestamp: new Date(0), data: null }),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
invalidateTime,
|
invalidateTime,
|
||||||
@@ -146,20 +148,24 @@ export default function DownloadClientsWidget({
|
|||||||
//Don't update already invalid data (new Date (0))
|
//Don't update already invalid data (new Date (0))
|
||||||
.filter(({ timestamp }) => dayjs().diff(timestamp) > invalidateTime && timestamp > new Date(0))
|
.filter(({ timestamp }) => dayjs().diff(timestamp) > invalidateTime && timestamp > new Date(0))
|
||||||
.map(({ integration }) => integration.id);
|
.map(({ integration }) => integration.id);
|
||||||
currentItemsHandlers.applyWhere(
|
utils.widget.downloads.getJobsAndStatuses.setData({ integrationIds }, (prevData) =>
|
||||||
({ integration }) => invalidIndexes.includes(integration.id),
|
prevData?.map((item) =>
|
||||||
//Set date to now so it won't update that integration for at least 30 seconds
|
invalidIndexes.includes(item.integration.id) ? item : { ...item, timestamp: new Date(0), data: null },
|
||||||
(item) => ({ ...item, timestamp: new Date(0), data: null }),
|
),
|
||||||
);
|
);
|
||||||
//Find id to update
|
utils.widget.downloads.getJobsAndStatuses.setData({ integrationIds }, (prevData) => {
|
||||||
const updateIndex = currentItems.findIndex((pair) => pair.integration.id === data.integration.id);
|
const updateIndex = currentItems.findIndex((pair) => pair.integration.id === data.integration.id);
|
||||||
if (updateIndex >= 0) {
|
if (updateIndex >= 0) {
|
||||||
//Update found index
|
//Update found index
|
||||||
currentItemsHandlers.setItem(updateIndex, data);
|
return prevData?.map((pair, index) => (index === updateIndex ? data : pair));
|
||||||
} else if (integrationIds.includes(data.integration.id)) {
|
} else if (integrationIds.includes(data.integration.id)) {
|
||||||
//Append index not found (new integration)
|
//Append index not found (new integration)
|
||||||
currentItemsHandlers.append(data);
|
return [...(prevData ?? []), data];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
//Reset no update timer
|
//Reset no update timer
|
||||||
invalidationTimer.clear();
|
invalidationTimer.clear();
|
||||||
invalidationTimer.start();
|
invalidationTimer.start();
|
||||||
@@ -227,7 +233,19 @@ export default function DownloadClientsWidget({
|
|||||||
)
|
)
|
||||||
//flatMap already sorts by integration by nature, add sorting by integration type (usenet | torrent)
|
//flatMap already sorts by integration by nature, add sorting by integration type (usenet | torrent)
|
||||||
.sort(({ type: typeA }, { type: typeB }) => typeA.length - typeB.length),
|
.sort(({ type: typeA }, { type: typeB }) => typeA.length - typeB.length),
|
||||||
[currentItems, integrationIds, options],
|
[
|
||||||
|
currentItems,
|
||||||
|
integrationIds,
|
||||||
|
integrationsWithInteractions,
|
||||||
|
mutateDeleteItem,
|
||||||
|
mutatePauseItem,
|
||||||
|
mutateResumeItem,
|
||||||
|
options.activeTorrentThreshold,
|
||||||
|
options.categoryFilter,
|
||||||
|
options.filterIsWhitelist,
|
||||||
|
options.showCompletedTorrent,
|
||||||
|
options.showCompletedUsenet,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
//Flatten Clients Array for which each elements has the integration and general client infos.
|
//Flatten Clients Array for which each elements has the integration and general client infos.
|
||||||
@@ -272,7 +290,14 @@ export default function DownloadClientsWidget({
|
|||||||
({ status: statusA }, { status: statusB }) =>
|
({ status: statusA }, { status: statusB }) =>
|
||||||
(statusA?.type.length ?? Infinity) - (statusB?.type.length ?? Infinity),
|
(statusA?.type.length ?? Infinity) - (statusB?.type.length ?? Infinity),
|
||||||
),
|
),
|
||||||
[currentItems, integrationIds, options],
|
[
|
||||||
|
currentItems,
|
||||||
|
integrationIds,
|
||||||
|
integrationsWithInteractions,
|
||||||
|
options.applyFilterToRatio,
|
||||||
|
options.categoryFilter,
|
||||||
|
options.filterIsWhitelist,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
//Check existing types between torrents and usenet
|
//Check existing types between torrents and usenet
|
||||||
@@ -327,37 +352,40 @@ export default function DownloadClientsWidget({
|
|||||||
};
|
};
|
||||||
|
|
||||||
//Base element in common with all columns
|
//Base element in common with all columns
|
||||||
const columnsDefBase = ({
|
const columnsDefBase = useCallback(
|
||||||
key,
|
({
|
||||||
showHeader,
|
key,
|
||||||
align,
|
showHeader,
|
||||||
}: {
|
align,
|
||||||
key: keyof ExtendedDownloadClientItem;
|
}: {
|
||||||
showHeader: boolean;
|
key: keyof ExtendedDownloadClientItem;
|
||||||
align?: "center" | "left" | "right" | "justify" | "char";
|
showHeader: boolean;
|
||||||
}): MRT_ColumnDef<ExtendedDownloadClientItem> => {
|
align?: "center" | "left" | "right" | "justify" | "char";
|
||||||
const style: MantineStyleProp = {
|
}): MRT_ColumnDef<ExtendedDownloadClientItem> => {
|
||||||
minWidth: 0,
|
const style: MantineStyleProp = {
|
||||||
width: "var(--column-width)",
|
minWidth: 0,
|
||||||
height: "var(--ratio-width)",
|
width: "var(--column-width)",
|
||||||
padding: "var(--space-size)",
|
height: "var(--ratio-width)",
|
||||||
transition: "unset",
|
padding: "var(--space-size)",
|
||||||
"--key-width": columnsRatios[key],
|
transition: "unset",
|
||||||
"--column-width": "calc((var(--key-width)/var(--total-width) * 100cqw))",
|
"--key-width": columnsRatios[key],
|
||||||
};
|
"--column-width": "calc((var(--key-width)/var(--total-width) * 100cqw))",
|
||||||
return {
|
};
|
||||||
id: key,
|
return {
|
||||||
accessorKey: key,
|
id: key,
|
||||||
header: key,
|
accessorKey: key,
|
||||||
size: columnsRatios[key],
|
header: key,
|
||||||
mantineTableBodyCellProps: { style, align },
|
size: columnsRatios[key],
|
||||||
mantineTableHeadCellProps: {
|
mantineTableBodyCellProps: { style, align },
|
||||||
style,
|
mantineTableHeadCellProps: {
|
||||||
align: isEditMode ? "center" : align,
|
style,
|
||||||
},
|
align: isEditMode ? "center" : align,
|
||||||
Header: () => (showHeader && !isEditMode ? <Text fw={700}>{t(`items.${key}.columnTitle`)}</Text> : ""),
|
},
|
||||||
};
|
Header: () => (showHeader && !isEditMode ? <Text fw={700}>{t(`items.${key}.columnTitle`)}</Text> : ""),
|
||||||
};
|
};
|
||||||
|
},
|
||||||
|
[isEditMode, t],
|
||||||
|
);
|
||||||
|
|
||||||
//Make columns and cell elements, Memoized to data with deps on data and EditMode
|
//Make columns and cell elements, Memoized to data with deps on data and EditMode
|
||||||
const columns = useMemo<MRT_ColumnDef<ExtendedDownloadClientItem>[]>(
|
const columns = useMemo<MRT_ColumnDef<ExtendedDownloadClientItem>[]>(
|
||||||
@@ -574,7 +602,7 @@ export default function DownloadClientsWidget({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[clickedIndex, isEditMode, data, integrationIds, options],
|
[columnsDefBase, t, tCommon],
|
||||||
);
|
);
|
||||||
|
|
||||||
//Table build and config
|
//Table build and config
|
||||||
@@ -698,10 +726,7 @@ interface ItemInfoModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ItemInfoModal = ({ items, currentIndex, opened, onClose }: ItemInfoModalProps) => {
|
const ItemInfoModal = ({ items, currentIndex, opened, onClose }: ItemInfoModalProps) => {
|
||||||
const item = useMemo<ExtendedDownloadClientItem | undefined>(
|
const item = useMemo<ExtendedDownloadClientItem | undefined>(() => items[currentIndex], [items, currentIndex]);
|
||||||
() => items[currentIndex],
|
|
||||||
[items, currentIndex, opened],
|
|
||||||
);
|
|
||||||
const t = useScopedI18n("widget.downloads.states");
|
const t = useScopedI18n("widget.downloads.states");
|
||||||
//The use case for "No item found" should be impossible, hence no translation
|
//The use case for "No item found" should be impossible, hence no translation
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const columnsSort = columnsList.filter((column) =>
|
|||||||
sortingExclusion.some((exclusion) => exclusion !== column),
|
sortingExclusion.some((exclusion) => exclusion !== column),
|
||||||
) as Exclude<typeof columnsList, (typeof sortingExclusion)[number]>;
|
) as Exclude<typeof columnsList, (typeof sortingExclusion)[number]>;
|
||||||
|
|
||||||
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("downloads", {
|
export const { definition, componentLoader } = createWidgetDefinition("downloads", {
|
||||||
icon: IconDownload,
|
icon: IconDownload,
|
||||||
options: optionsBuilder.from(
|
options: optionsBuilder.from(
|
||||||
(factory) => ({
|
(factory) => ({
|
||||||
@@ -105,6 +105,4 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
supportedIntegrations: getIntegrationKindsByCategory("downloadClient"),
|
supportedIntegrations: getIntegrationKindsByCategory("downloadClient"),
|
||||||
})
|
}).withDynamicImport(() => import("./component"));
|
||||||
.withServerData(() => import("./serverData"))
|
|
||||||
.withDynamicImport(() => import("./component"));
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
|
||||||
|
|
||||||
import type { WidgetProps } from "../definition";
|
|
||||||
|
|
||||||
export default async function getServerDataAsync({ integrationIds }: WidgetProps<"downloads">) {
|
|
||||||
if (integrationIds.length === 0) {
|
|
||||||
return {
|
|
||||||
initialData: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const jobsAndStatuses = await api.widget.downloads.getJobsAndStatuses({
|
|
||||||
integrationIds,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
initialData: jobsAndStatuses,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useDisclosure, useElementSize, useListState } from "@mantine/hooks";
|
import { useDisclosure, useElementSize } from "@mantine/hooks";
|
||||||
import {
|
import {
|
||||||
IconBrain,
|
IconBrain,
|
||||||
IconClock,
|
IconClock,
|
||||||
@@ -29,42 +29,91 @@ import {
|
|||||||
IconTemperature,
|
IconTemperature,
|
||||||
IconVersions,
|
IconVersions,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import duration from "dayjs/plugin/duration";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import type { HealthMonitoring } from "@homarr/integrations";
|
||||||
import type { TranslationFunction } from "@homarr/translation";
|
import type { TranslationFunction } from "@homarr/translation";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { NoIntegrationSelectedError } from "../errors";
|
import { NoIntegrationSelectedError } from "../errors";
|
||||||
|
|
||||||
export default function HealthMonitoringWidget({
|
dayjs.extend(duration);
|
||||||
options,
|
|
||||||
integrationIds,
|
export default function HealthMonitoringWidget({ options, integrationIds }: WidgetComponentProps<"healthMonitoring">) {
|
||||||
serverData,
|
|
||||||
}: WidgetComponentProps<"healthMonitoring">) {
|
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const [healthData] = useListState(serverData?.initialData ?? []);
|
const [healthData] = clientApi.widget.healthMonitoring.getHealthStatus.useSuspenseQuery(
|
||||||
|
{
|
||||||
|
integrationIds,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
retry: false,
|
||||||
|
select: (data) =>
|
||||||
|
data.filter(
|
||||||
|
(
|
||||||
|
health,
|
||||||
|
): health is {
|
||||||
|
integrationId: string;
|
||||||
|
integrationName: string;
|
||||||
|
healthInfo: HealthMonitoring;
|
||||||
|
timestamp: Date;
|
||||||
|
} => health.healthInfo !== null,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
|
const utils = clientApi.useUtils();
|
||||||
|
|
||||||
|
clientApi.widget.healthMonitoring.subscribeHealthStatus.useSubscription(
|
||||||
|
{ integrationIds },
|
||||||
|
{
|
||||||
|
onData(data) {
|
||||||
|
utils.widget.healthMonitoring.getHealthStatus.setData({ integrationIds }, (prevData) => {
|
||||||
|
if (!prevData) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const newData = prevData.map((item) =>
|
||||||
|
item.integrationId === data.integrationId
|
||||||
|
? { ...item, healthInfo: data.healthInfo, timestamp: new Date(0) }
|
||||||
|
: item,
|
||||||
|
);
|
||||||
|
return newData.filter(
|
||||||
|
(
|
||||||
|
health,
|
||||||
|
): health is {
|
||||||
|
integrationId: string;
|
||||||
|
integrationName: string;
|
||||||
|
healthInfo: HealthMonitoring;
|
||||||
|
timestamp: Date;
|
||||||
|
} => health.healthInfo !== null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (integrationIds.length === 0) {
|
if (integrationIds.length === 0) {
|
||||||
throw new NoIntegrationSelectedError();
|
throw new NoIntegrationSelectedError();
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Box h="100%" className="health-monitoring">
|
<Stack h="100%" gap="2.5cqmin" className="health-monitoring">
|
||||||
{healthData.map(({ integrationId, integrationName, healthInfo }) => {
|
{healthData.map(({ integrationId, integrationName, healthInfo, timestamp }) => {
|
||||||
const memoryUsage = formatMemoryUsage(healthInfo.memAvailable, healthInfo.memUsed);
|
|
||||||
const disksData = matchFileSystemAndSmart(healthInfo.fileSystem, healthInfo.smart);
|
const disksData = matchFileSystemAndSmart(healthInfo.fileSystem, healthInfo.smart);
|
||||||
const { ref, width } = useElementSize();
|
const memoryUsage = formatMemoryUsage(healthInfo.memAvailable, healthInfo.memUsed);
|
||||||
const ringSize = width * 0.95;
|
|
||||||
const ringThickness = width / 10;
|
|
||||||
const progressSize = width * 0.2;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Stack
|
||||||
|
gap="2.5cqmin"
|
||||||
key={integrationId}
|
key={integrationId}
|
||||||
h="100%"
|
h="100%"
|
||||||
className={`health-monitoring-information health-monitoring-${integrationName}`}
|
className={`health-monitoring-information health-monitoring-${integrationName}`}
|
||||||
|
p="2.5cqmin"
|
||||||
>
|
>
|
||||||
<Card className="health-monitoring-information-card" m="2.5cqmin" p="2.5cqmin" withBorder>
|
<Card className="health-monitoring-information-card" p="2.5cqmin" withBorder>
|
||||||
<Flex
|
<Flex
|
||||||
className="health-monitoring-information-card-elements"
|
className="health-monitoring-information-card-elements"
|
||||||
h="100%"
|
h="100%"
|
||||||
@@ -102,21 +151,30 @@ export default function HealthMonitoringWidget({
|
|||||||
className="health-monitoring-information-processor"
|
className="health-monitoring-information-processor"
|
||||||
icon={<IconCpu2 size="1.5cqmin" />}
|
icon={<IconCpu2 size="1.5cqmin" />}
|
||||||
>
|
>
|
||||||
{t("widget.healthMonitoring.popover.processor")} {healthInfo.cpuModelName}
|
{t("widget.healthMonitoring.popover.processor", { cpuModelName: healthInfo.cpuModelName })}
|
||||||
</List.Item>
|
</List.Item>
|
||||||
<List.Item
|
<List.Item
|
||||||
className="health-monitoring-information-memory"
|
className="health-monitoring-information-memory"
|
||||||
icon={<IconBrain size="1.5cqmin" />}
|
icon={<IconBrain size="1.5cqmin" />}
|
||||||
>
|
>
|
||||||
{t("widget.healthMonitoring.popover.memory")} {memoryUsage.memTotal.GB}GiB -{" "}
|
{t("widget.healthMonitoring.popover.memory", { memory: memoryUsage.memTotal.GB })}
|
||||||
{t("widget.healthMonitoring.popover.memAvailable")} {memoryUsage.memFree.GB}GiB (
|
</List.Item>
|
||||||
{memoryUsage.memFree.percent}%)
|
<List.Item
|
||||||
|
className="health-monitoring-information-memory"
|
||||||
|
icon={<IconBrain size="1.5cqmin" />}
|
||||||
|
>
|
||||||
|
{t("widget.healthMonitoring.popover.memoryAvailable", {
|
||||||
|
memoryAvailable: memoryUsage.memFree.GB,
|
||||||
|
percent: memoryUsage.memFree.percent,
|
||||||
|
})}
|
||||||
</List.Item>
|
</List.Item>
|
||||||
<List.Item
|
<List.Item
|
||||||
className="health-monitoring-information-version"
|
className="health-monitoring-information-version"
|
||||||
icon={<IconVersions size="1.5cqmin" />}
|
icon={<IconVersions size="1.5cqmin" />}
|
||||||
>
|
>
|
||||||
{t("widget.healthMonitoring.popover.version")} {healthInfo.version}
|
{t("widget.healthMonitoring.popover.version", {
|
||||||
|
version: healthInfo.version,
|
||||||
|
})}
|
||||||
</List.Item>
|
</List.Item>
|
||||||
<List.Item
|
<List.Item
|
||||||
className="health-monitoring-information-uptime"
|
className="health-monitoring-information-uptime"
|
||||||
@@ -147,92 +205,28 @@ export default function HealthMonitoringWidget({
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
</Box>
|
</Box>
|
||||||
{options.cpu && (
|
{options.cpu && <CpuRing cpuUtilization={healthInfo.cpuUtilization} />}
|
||||||
<Box ref={ref} w="100%" h="100%" className="health-monitoring-cpu">
|
|
||||||
<RingProgress
|
|
||||||
className="health-monitoring-cpu-utilization"
|
|
||||||
roundCaps
|
|
||||||
size={ringSize}
|
|
||||||
thickness={ringThickness}
|
|
||||||
label={
|
|
||||||
<Center style={{ flexDirection: "column" }}>
|
|
||||||
<Text
|
|
||||||
className="health-monitoring-cpu-utilization-value"
|
|
||||||
size="3cqmin"
|
|
||||||
>{`${healthInfo.cpuUtilization.toFixed(2)}%`}</Text>
|
|
||||||
<IconCpu className="health-monitoring-cpu-utilization-icon" size="7cqmin" />
|
|
||||||
</Center>
|
|
||||||
}
|
|
||||||
sections={[
|
|
||||||
{
|
|
||||||
value: Number(healthInfo.cpuUtilization.toFixed(2)),
|
|
||||||
color: progressColor(Number(healthInfo.cpuUtilization.toFixed(2))),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
{healthInfo.cpuTemp && options.cpu && (
|
{healthInfo.cpuTemp && options.cpu && (
|
||||||
<Box ref={ref} w="100%" h="100%" className="health-monitoring-cpu-temperature">
|
<CpuTempRing fahrenheit={options.fahrenheit} cpuTemp={healthInfo.cpuTemp} />
|
||||||
<RingProgress
|
|
||||||
ref={ref}
|
|
||||||
className="health-monitoring-cpu-temp"
|
|
||||||
roundCaps
|
|
||||||
size={ringSize}
|
|
||||||
thickness={ringThickness}
|
|
||||||
label={
|
|
||||||
<Center style={{ flexDirection: "column" }}>
|
|
||||||
<Text className="health-monitoring-cpu-temp-value" size="3cqmin">
|
|
||||||
{options.fahrenheit
|
|
||||||
? `${(healthInfo.cpuTemp * 1.8 + 32).toFixed(1)}°F`
|
|
||||||
: `${healthInfo.cpuTemp}°C`}
|
|
||||||
</Text>
|
|
||||||
<IconCpu className="health-monitoring-cpu-temp-icon" size="7cqmin" />
|
|
||||||
</Center>
|
|
||||||
}
|
|
||||||
sections={[
|
|
||||||
{
|
|
||||||
value: healthInfo.cpuTemp,
|
|
||||||
color: progressColor(healthInfo.cpuTemp),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
{options.memory && (
|
|
||||||
<Box ref={ref} w="100%" h="100%" className="health-monitoring-memory">
|
|
||||||
<RingProgress
|
|
||||||
className="health-monitoring-memory-use"
|
|
||||||
roundCaps
|
|
||||||
size={ringSize}
|
|
||||||
thickness={ringThickness}
|
|
||||||
label={
|
|
||||||
<Center style={{ flexDirection: "column" }}>
|
|
||||||
<Text className="health-monitoring-memory-value" size="3cqmin">
|
|
||||||
{memoryUsage.memUsed.GB}GiB
|
|
||||||
</Text>
|
|
||||||
<IconBrain className="health-monitoring-memory-icon" size="7cqmin" />
|
|
||||||
</Center>
|
|
||||||
}
|
|
||||||
sections={[
|
|
||||||
{
|
|
||||||
value: Number(memoryUsage.memUsed.percent),
|
|
||||||
color: progressColor(Number(memoryUsage.memUsed.percent)),
|
|
||||||
tooltip: `${memoryUsage.memUsed.percent}%`,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)}
|
)}
|
||||||
|
{options.memory && <MemoryRing available={healthInfo.memAvailable} used={healthInfo.memUsed} />}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
<Text
|
||||||
|
className="health-monitoring-status-update-time"
|
||||||
|
c="dimmed"
|
||||||
|
size="3.5cqmin"
|
||||||
|
ta="center"
|
||||||
|
mb="2.5cqmin"
|
||||||
|
>
|
||||||
|
{t("widget.healthMonitoring.popover.lastSeen", { lastSeen: dayjs(timestamp).fromNow() })}
|
||||||
|
</Text>
|
||||||
</Card>
|
</Card>
|
||||||
{options.fileSystem &&
|
{options.fileSystem &&
|
||||||
disksData.map((disk) => {
|
disksData.map((disk) => {
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className="health-monitoring-disk-card"
|
className={`health-monitoring-disk-card health-monitoring-disk-card-${integrationName}`}
|
||||||
key={disk.deviceName}
|
key={disk.deviceName}
|
||||||
m="2.5cqmin"
|
|
||||||
p="2.5cqmin"
|
p="2.5cqmin"
|
||||||
withBorder
|
withBorder
|
||||||
>
|
>
|
||||||
@@ -258,14 +252,14 @@ export default function HealthMonitoringWidget({
|
|||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Progress.Root className="health-monitoring-disk-use" size={progressSize}>
|
<Progress.Root className="health-monitoring-disk-use" h="6cqmin">
|
||||||
<Tooltip label={disk.used}>
|
<Tooltip label={disk.used}>
|
||||||
<Progress.Section
|
<Progress.Section
|
||||||
value={disk.percentage}
|
value={disk.percentage}
|
||||||
color={progressColor(disk.percentage)}
|
color={progressColor(disk.percentage)}
|
||||||
className="health-monitoring-disk-use-percentage"
|
className="health-monitoring-disk-use-percentage"
|
||||||
>
|
>
|
||||||
<Progress.Label className="health-monitoring-disk-use-value">
|
<Progress.Label className="health-monitoring-disk-use-value" fz="2.5cqmin">
|
||||||
{t("widget.healthMonitoring.popover.used")}
|
{t("widget.healthMonitoring.popover.used")}
|
||||||
</Progress.Label>
|
</Progress.Label>
|
||||||
</Progress.Section>
|
</Progress.Section>
|
||||||
@@ -283,8 +277,8 @@ export default function HealthMonitoringWidget({
|
|||||||
value={100 - disk.percentage}
|
value={100 - disk.percentage}
|
||||||
color="default"
|
color="default"
|
||||||
>
|
>
|
||||||
<Progress.Label className="health-monitoring-disk-available-value">
|
<Progress.Label className="health-monitoring-disk-available-value" fz="2.5cqmin">
|
||||||
{t("widget.healthMonitoring.popover.diskAvailable")}
|
{t("widget.healthMonitoring.popover.available")}
|
||||||
</Progress.Label>
|
</Progress.Label>
|
||||||
</Progress.Section>
|
</Progress.Section>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -292,17 +286,20 @@ export default function HealthMonitoringWidget({
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Box>
|
</Stack>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Box>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const formatUptime = (uptimeInSeconds: number, t: TranslationFunction) => {
|
export const formatUptime = (uptimeInSeconds: number, t: TranslationFunction) => {
|
||||||
const days = Math.floor(uptimeInSeconds / (60 * 60 * 24));
|
const uptimeDuration = dayjs.duration(uptimeInSeconds, "seconds");
|
||||||
const remainingHours = Math.floor((uptimeInSeconds % (60 * 60 * 24)) / 3600);
|
const days = uptimeDuration.days();
|
||||||
return t("widget.healthMonitoring.popover.uptime", { days, hours: remainingHours });
|
const hours = uptimeDuration.hours();
|
||||||
|
const minutes = uptimeDuration.minutes();
|
||||||
|
|
||||||
|
return t("widget.healthMonitoring.popover.uptime", { days, hours, minutes });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const progressColor = (percentage: number) => {
|
export const progressColor = (percentage: number) => {
|
||||||
@@ -341,6 +338,95 @@ export const matchFileSystemAndSmart = (fileSystems: FileSystem[], smartData: Sm
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CpuRing = ({ cpuUtilization }: { cpuUtilization: number }) => {
|
||||||
|
const { width, ref } = useElementSize();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box ref={ref} w="100%" h="100%" className="health-monitoring-cpu">
|
||||||
|
<RingProgress
|
||||||
|
className="health-monitoring-cpu-utilization"
|
||||||
|
roundCaps
|
||||||
|
size={width * 0.95}
|
||||||
|
thickness={width / 10}
|
||||||
|
label={
|
||||||
|
<Center style={{ flexDirection: "column" }}>
|
||||||
|
<Text
|
||||||
|
className="health-monitoring-cpu-utilization-value"
|
||||||
|
size="3cqmin"
|
||||||
|
>{`${cpuUtilization.toFixed(2)}%`}</Text>
|
||||||
|
<IconCpu className="health-monitoring-cpu-utilization-icon" size="7cqmin" />
|
||||||
|
</Center>
|
||||||
|
}
|
||||||
|
sections={[
|
||||||
|
{
|
||||||
|
value: Number(cpuUtilization.toFixed(2)),
|
||||||
|
color: progressColor(Number(cpuUtilization.toFixed(2))),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CpuTempRing = ({ fahrenheit, cpuTemp }: { fahrenheit: boolean; cpuTemp: number }) => {
|
||||||
|
const { width, ref } = useElementSize();
|
||||||
|
return (
|
||||||
|
<Box ref={ref} w="100%" h="100%" className="health-monitoring-cpu-temperature">
|
||||||
|
<RingProgress
|
||||||
|
className="health-monitoring-cpu-temp"
|
||||||
|
roundCaps
|
||||||
|
size={width * 0.95}
|
||||||
|
thickness={width / 10}
|
||||||
|
label={
|
||||||
|
<Center style={{ flexDirection: "column" }}>
|
||||||
|
<Text className="health-monitoring-cpu-temp-value" size="3cqmin">
|
||||||
|
{fahrenheit ? `${(cpuTemp * 1.8 + 32).toFixed(1)}°F` : `${cpuTemp}°C`}
|
||||||
|
</Text>
|
||||||
|
<IconCpu className="health-monitoring-cpu-temp-icon" size="7cqmin" />
|
||||||
|
</Center>
|
||||||
|
}
|
||||||
|
sections={[
|
||||||
|
{
|
||||||
|
value: cpuTemp,
|
||||||
|
color: progressColor(cpuTemp),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MemoryRing = ({ available, used }: { available: string; used: string }) => {
|
||||||
|
const { width, ref } = useElementSize();
|
||||||
|
const memoryUsage = formatMemoryUsage(available, used);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box ref={ref} w="100%" h="100%" className="health-monitoring-memory">
|
||||||
|
<RingProgress
|
||||||
|
className="health-monitoring-memory-use"
|
||||||
|
roundCaps
|
||||||
|
size={width * 0.95}
|
||||||
|
thickness={width / 10}
|
||||||
|
label={
|
||||||
|
<Center style={{ flexDirection: "column" }}>
|
||||||
|
<Text className="health-monitoring-memory-value" size="3cqmin">
|
||||||
|
{memoryUsage.memUsed.GB}GiB
|
||||||
|
</Text>
|
||||||
|
<IconBrain className="health-monitoring-memory-icon" size="7cqmin" />
|
||||||
|
</Center>
|
||||||
|
}
|
||||||
|
sections={[
|
||||||
|
{
|
||||||
|
value: Number(memoryUsage.memUsed.percent),
|
||||||
|
color: progressColor(Number(memoryUsage.memUsed.percent)),
|
||||||
|
tooltip: `${memoryUsage.memUsed.percent}%`,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const formatMemoryUsage = (memFree: string, memUsed: string) => {
|
export const formatMemoryUsage = (memFree: string, memUsed: string) => {
|
||||||
const memFreeBytes = Number(memFree);
|
const memFreeBytes = Number(memFree);
|
||||||
const memUsedBytes = Number(memUsed);
|
const memUsedBytes = Number(memUsed);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { IconHeartRateMonitor, IconServerOff } from "@tabler/icons-react";
|
|||||||
import { createWidgetDefinition } from "../definition";
|
import { createWidgetDefinition } from "../definition";
|
||||||
import { optionsBuilder } from "../options";
|
import { optionsBuilder } from "../options";
|
||||||
|
|
||||||
export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("healthMonitoring", {
|
export const { definition, componentLoader } = createWidgetDefinition("healthMonitoring", {
|
||||||
icon: IconHeartRateMonitor,
|
icon: IconHeartRateMonitor,
|
||||||
options: optionsBuilder.from((factory) => ({
|
options: optionsBuilder.from((factory) => ({
|
||||||
fahrenheit: factory.switch({
|
fahrenheit: factory.switch({
|
||||||
@@ -26,6 +26,4 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef
|
|||||||
message: (t) => t("widget.healthMonitoring.error.internalServerError"),
|
message: (t) => t("widget.healthMonitoring.error.internalServerError"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
}).withDynamicImport(() => import("./component"));
|
||||||
.withServerData(() => import("./serverData"))
|
|
||||||
.withDynamicImport(() => import("./component"));
|
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
|
||||||
|
|
||||||
import type { WidgetProps } from "../definition";
|
|
||||||
|
|
||||||
export default async function getServerDataAsync({ integrationIds }: WidgetProps<"healthMonitoring">) {
|
|
||||||
if (integrationIds.length === 0) {
|
|
||||||
return {
|
|
||||||
initialData: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const currentHealthInfo = await api.widget.healthMonitoring.getHealthStatus({
|
|
||||||
integrationIds,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
initialData: currentHealthInfo.filter((health) => health !== null),
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return {
|
|
||||||
initialData: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -30,8 +30,6 @@ export { reduceWidgetOptionsWithDefaultValues } from "./options";
|
|||||||
|
|
||||||
export type { WidgetDefinition } from "./definition";
|
export type { WidgetDefinition } from "./definition";
|
||||||
export { WidgetEditModal } from "./modals/widget-edit-modal";
|
export { WidgetEditModal } from "./modals/widget-edit-modal";
|
||||||
export { useServerDataFor } from "./server/provider";
|
|
||||||
export { GlobalItemServerDataRunner } from "./server/runner";
|
|
||||||
export type { WidgetComponentProps };
|
export type { WidgetComponentProps };
|
||||||
|
|
||||||
export const widgetImports = {
|
export const widgetImports = {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user