feat: add app widget (#206)

* refactor: move server api to api package

* feat: add app widget

* refactor: add element size for widget components on board

* feat: add resize listener for widget width

* feat: add widget app input

* refactor: add better responsibe layout, add missing translations

* fix: ci issues

* fix: deepsource issues

* chore: address pull request feedback
This commit is contained in:
Meier Lukas
2024-03-12 21:23:25 +01:00
committed by GitHub
parent 7d5b999ab8
commit c4ff968cbc
31 changed files with 561 additions and 78 deletions

View File

@@ -0,0 +1,13 @@
.appIcon {
max-height: 100%;
max-width: 100%;
overflow: auto;
flex: 1;
object-fit: contain;
scale: 0.8;
transition: scale 0.2s ease-in-out;
}
.appIcon:hover {
scale: 0.9;
}

View File

@@ -0,0 +1,135 @@
"use client";
import type { PropsWithChildren } from "react";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import {
Center,
Flex,
IconDeviceDesktopX,
Loader,
Stack,
Text,
Tooltip,
UnstyledButton,
} from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import classes from "./app.module.css";
export default function AppWidget({
options,
serverData,
isEditMode,
width,
height,
}: WidgetComponentProps<"app">) {
const t = useScopedI18n("widget.app");
const {
data: app,
isPending,
isError,
} = clientApi.app.byId.useQuery(
{
id: options.appId,
},
{
initialData:
// We need to check if the id's match because otherwise the same initialData for a changed id will be used
serverData?.app.id === options.appId ? serverData?.app : undefined,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
);
if (isPending) {
return (
<Center h="100%">
<Loader />
</Center>
);
}
if (isError) {
return (
<Tooltip.Floating label={t("error.notFound.tooltip")}>
<Stack gap="xs" align="center" justify="center" h="100%" w="100%">
<IconDeviceDesktopX size={width >= 96 ? "2rem" : "1.5rem"} />
{width >= 96 && (
<Text ta="center" size="sm">
{t("error.notFound.label")}
</Text>
)}
</Stack>
</Tooltip.Floating>
);
}
return (
<AppLink
href={app?.href ?? ""}
openInNewTab={options.openInNewTab}
enabled={Boolean(app?.href) && !isEditMode}
>
<Flex align="center" justify="center" h="100%">
<Tooltip.Floating
label={app?.description}
position="right-start"
multiline
disabled={!options.showDescriptionTooltip || !app?.description}
styles={{ tooltip: { maxWidth: 300 } }}
>
<Flex
h="100%"
direction="column"
align="center"
gap={0}
style={{
overflow: "visible",
flexGrow: 5,
}}
>
{height >= 96 && (
<Text fw={700} ta="center">
{app?.name}
</Text>
)}
<img
src={app?.iconUrl}
alt={app?.name}
className={classes.appIcon}
/>
</Flex>
</Tooltip.Floating>
</Flex>
</AppLink>
);
}
interface AppLinkProps {
href: string;
openInNewTab: boolean;
enabled: boolean;
}
const AppLink = ({
href,
openInNewTab,
enabled,
children,
}: PropsWithChildren<AppLinkProps>) =>
enabled ? (
<UnstyledButton
component="a"
href={href}
target={openInNewTab ? "_blank" : undefined}
h="100%"
w="100%"
>
{children}
</UnstyledButton>
) : (
children
);

View File

@@ -0,0 +1,16 @@
import { IconApps } from "@homarr/ui";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader, serverDataLoader } =
createWidgetDefinition("app", {
icon: IconApps,
options: optionsBuilder.from((factory) => ({
appId: factory.app(),
openInNewTab: factory.switch({ defaultValue: true }),
showDescriptionTooltip: factory.switch({ defaultValue: false }),
})),
})
.withServerData(() => import("./serverData"))
.withDynamicImport(() => import("./component"));

View File

@@ -0,0 +1,10 @@
"use server";
import { api } from "@homarr/api/server";
import type { WidgetProps } from "../definition";
export default async function getServerData({ options }: WidgetProps<"app">) {
const app = await api.app.byId({ id: options.appId });
return { app };
}