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

@@ -1,4 +1,5 @@
import type { WidgetOptionType } from "../options";
import { WidgetAppInput } from "./widget-app-input";
import { WidgetMultiSelectInput } from "./widget-multiselect-input";
import { WidgetNumberInput } from "./widget-number-input";
import { WidgetSelectInput } from "./widget-select-input";
@@ -15,6 +16,7 @@ const mapping = {
select: WidgetSelectInput,
slider: WidgetSliderInput,
switch: WidgetSwitchInput,
app: WidgetAppInput,
} satisfies Record<WidgetOptionType, unknown>;
export const getInputForType = <TType extends WidgetOptionType>(

View File

@@ -0,0 +1,97 @@
"use client";
import { memo, useMemo } from "react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { SelectProps } from "@homarr/ui";
import { Group, IconCheck, Loader, Select } from "@homarr/ui";
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);
const form = useFormContext();
const { data: apps, isPending } = clientApi.app.selectable.useQuery();
const currentApp = useMemo(
() => apps?.find((app) => app.id === form.values.options.appId),
[apps, form.values.options.appId],
);
return (
<Select
label={t("label")}
searchable
limit={10}
leftSection={
<MemoizedLeftSection isPending={isPending} currentApp={currentApp} />
}
renderOption={renderSelectOption}
data={
apps?.map((app) => ({
label: app.name,
value: app.id,
iconUrl: app.iconUrl,
})) ?? []
}
description={options.withDescription ? t("description") : undefined}
{...form.getInputProps(`options.${property}`)}
/>
);
};
const iconProps = {
stroke: 1.5,
color: "currentColor",
opacity: 0.6,
size: 18,
};
const renderSelectOption: SelectProps["renderOption"] = ({
option,
checked,
}) => (
<Group flex="1" gap="xs">
{"iconUrl" in option && typeof option.iconUrl === "string" ? (
<img width={20} height={20} src={option.iconUrl} alt={option.label} />
) : null}
{option.label}
{checked && (
<IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />
)}
</Group>
);
interface LeftSectionProps {
isPending: boolean;
currentApp: RouterOutputs["app"]["selectable"][number] | undefined;
}
const size = 20;
const LeftSection = ({ isPending, currentApp }: LeftSectionProps) => {
if (isPending) {
return <Loader size={size} />;
}
if (currentApp) {
return (
<img
width={size}
height={size}
src={currentApp.iconUrl}
alt={currentApp.name}
/>
);
}
return null;
};
const MemoizedLeftSection = memo(LeftSection);

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 };
}

View File

@@ -104,6 +104,10 @@ type inferServerDataForKind<TKind extends WidgetKind> =
export type WidgetComponentProps<TKind extends WidgetKind> =
WidgetProps<TKind> & {
serverData?: inferServerDataForKind<TKind>;
} & {
isEditMode: boolean;
width: number;
height: number;
};
type inferIntegrationsFromDefinition<TDefinition extends WidgetDefinition> =

View File

@@ -5,6 +5,7 @@ import type { Loader } from "next/dynamic";
import type { WidgetKind } from "@homarr/definitions";
import { Loader as UiLoader } from "@homarr/ui";
import * as app from "./app";
import * as clock from "./clock";
import type { WidgetComponentProps } from "./definition";
import type { WidgetImportRecord } from "./import";
@@ -19,6 +20,7 @@ export { useServerDataFor } from "./server/provider";
export const widgetImports = {
clock,
weather,
app,
} satisfies WidgetImportRecord;
export type WidgetImports = typeof widgetImports;

View File

@@ -104,6 +104,11 @@ const optionsFactory = {
defaultValue: input?.defaultValue ?? [],
withDescription: input?.withDescription ?? false,
}),
app: (input?: Omit<CommonInput<string>, "defaultValue">) => ({
type: "app" as const,
defaultValue: "",
withDescription: input?.withDescription ?? false,
}),
};
type WidgetOptionFactory = typeof optionsFactory;

View File

@@ -3,7 +3,7 @@ import { Suspense } from "react";
import type { RouterOutputs } from "@homarr/api";
import { widgetImports } from "..";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "..";
import { ClientServerDataInitalizer } from "./client";
import { GlobalItemServerDataProvider } from "./provider";
@@ -32,13 +32,19 @@ interface ItemDataLoaderProps {
item: Board["sections"][number]["items"][number];
}
const ItemDataLoader = /*async*/ ({ item }: ItemDataLoaderProps) => {
const ItemDataLoader = async ({ item }: ItemDataLoaderProps) => {
const widgetImport = widgetImports[item.kind];
if (!("serverDataLoader" in widgetImport)) {
return <ClientServerDataInitalizer id={item.id} serverData={undefined} />;
}
//const loader = await widgetImport.serverDataLoader();
//const data = await loader.default(item as never);
//return <ClientServerDataInitalizer id={item.id} serverData={data} />;
return null;
const loader = await widgetImport.serverDataLoader();
const optionsWithDefault = reduceWidgetOptionsWithDefaultValues(
item.kind,
item.options,
);
const data = await loader.default({
...item,
options: optionsWithDefault as never,
});
return <ClientServerDataInitalizer id={item.id} serverData={data} />;
};