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