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:
@@ -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>(
|
||||
|
||||
97
packages/widgets/src/_inputs/widget-app-input.tsx
Normal file
97
packages/widgets/src/_inputs/widget-app-input.tsx
Normal 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);
|
||||
13
packages/widgets/src/app/app.module.css
Normal file
13
packages/widgets/src/app/app.module.css
Normal 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;
|
||||
}
|
||||
135
packages/widgets/src/app/component.tsx
Normal file
135
packages/widgets/src/app/component.tsx
Normal 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
|
||||
);
|
||||
16
packages/widgets/src/app/index.ts
Normal file
16
packages/widgets/src/app/index.ts
Normal 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"));
|
||||
10
packages/widgets/src/app/serverData.ts
Normal file
10
packages/widgets/src/app/serverData.ts
Normal 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 };
|
||||
}
|
||||
@@ -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> =
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user