diff --git a/apps/nextjs/src/app/[locale]/widgets/[sort]/_content.tsx b/apps/nextjs/src/app/[locale]/widgets/[sort]/_content.tsx new file mode 100644 index 000000000..de3e36f13 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/widgets/[sort]/_content.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useState } from "react"; +import type { WidgetOptionDefinition } from "node_modules/@homarr/widgets/src/options"; + +import type { IntegrationKind } from "@homarr/definitions"; +import { ActionIcon, Affix, IconPencil } from "@homarr/ui"; +import type { WidgetSort } from "@homarr/widgets"; +import { + loadWidgetDynamic, + reduceWidgetOptionsWithDefaultValues, + widgetImports, +} from "@homarr/widgets"; + +import { modalEvents } from "../../modals"; + +interface WidgetPreviewPageContentProps { + sort: WidgetSort; + integrationData: { + id: string; + name: string; + url: string; + kind: IntegrationKind; + }[]; +} + +export const WidgetPreviewPageContent = ({ + sort, + integrationData, +}: WidgetPreviewPageContentProps) => { + const currentDefinition = widgetImports[sort].definition; + const options = currentDefinition.options as Record< + string, + WidgetOptionDefinition + >; + const [state, setState] = useState<{ + options: Record; + integrations: string[]; + }>({ + options: reduceWidgetOptionsWithDefaultValues(options), + integrations: [], + }); + + const Comp = loadWidgetDynamic(sort); + + return ( + <> + integrationData.find((x) => x.id === id)!, + )} + /> + + { + return modalEvents.openManagedModal({ + modal: "widgetEditModal", + innerProps: { + sort, + definition: currentDefinition.options, + state: [state, setState], + integrationData: integrationData.filter( + (integration) => + "supportedIntegrations" in currentDefinition && + currentDefinition.supportedIntegrations.some( + (kind) => kind === integration.kind, + ), + ), + integrationSupport: + "supportedIntegrations" in currentDefinition, + }, + }); + }} + > + + + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/widgets/[sort]/page.tsx b/apps/nextjs/src/app/[locale]/widgets/[sort]/page.tsx index 95adfab81..75419e656 100644 --- a/apps/nextjs/src/app/[locale]/widgets/[sort]/page.tsx +++ b/apps/nextjs/src/app/[locale]/widgets/[sort]/page.tsx @@ -1,48 +1,34 @@ -"use client"; - import type { PropsWithChildren } from "react"; -import { useState } from "react"; import { notFound } from "next/navigation"; -import { ActionIcon, Affix, Center, IconPencil } from "@homarr/ui"; +import { db } from "@homarr/db"; +import { Center } from "@homarr/ui"; import type { WidgetSort } from "@homarr/widgets"; -import { loadWidgetDynamic, widgetImports } from "@homarr/widgets"; +import { widgetImports } from "@homarr/widgets"; -import { modalEvents } from "../../modals"; +import { WidgetPreviewPageContent } from "./_content"; type Props = PropsWithChildren<{ params: { sort: string } }>; -export default function WidgetPreview(props: Props) { - const [options, setOptions] = useState>({}); +export default async function WidgetPreview(props: Props) { if (!(props.params.sort in widgetImports)) { notFound(); } + const integrationData = await db.query.integrations.findMany({ + columns: { + id: true, + name: true, + url: true, + kind: true, + }, + }); + const sort = props.params.sort as WidgetSort; - const Comp = loadWidgetDynamic(sort); return (
- - - { - return modalEvents.openManagedModal({ - modal: "widgetEditModal", - innerProps: { - sort, - definition: widgetImports[sort].definition.options, - state: [options, setOptions], - }, - }); - }} - > - - - +
); } diff --git a/packages/translation/src/lang/de.ts b/packages/translation/src/lang/de.ts index 90946cf00..06325fb40 100644 --- a/packages/translation/src/lang/de.ts +++ b/packages/translation/src/lang/de.ts @@ -148,6 +148,9 @@ export default { cancel: "Abbrechen", confirm: "Bestätigen", }, + multiSelect: { + placeholder: "Wähle eine oder mehrere Optionen aus", + }, noResults: "Keine Ergebnisse gefunden", search: { placeholder: "Suche nach etwas...", @@ -155,17 +158,23 @@ export default { }, }, widget: { + editModal: { + integrations: { + label: "Integrationen", + }, + }, clock: { option: { is24HourFormat: { label: "24-Stunden Format", - description: "Use 24-hour format instead of 12-hour format", + description: + "Verwende das 24-Stunden Format anstelle des 12-Stunden Formats", }, isLocaleTime: { - label: "Use locale time", + label: "Lokale Zeit verwenden", }, timezone: { - label: "Timezone", + label: "Zeitzone", }, }, }, diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts index 9384e3988..e45f17469 100644 --- a/packages/translation/src/lang/en.ts +++ b/packages/translation/src/lang/en.ts @@ -147,6 +147,9 @@ export default { cancel: "Cancel", confirm: "Confirm", }, + multiSelect: { + placeholder: "Pick one or more values", + }, search: { placeholder: "Search for anything...", nothingFound: "Nothing found", @@ -154,6 +157,11 @@ export default { noResults: "No results found", }, widget: { + editModal: { + integrations: { + label: "Integrations", + }, + }, clock: { option: { is24HourFormat: { diff --git a/packages/widgets/package.json b/packages/widgets/package.json index 0d6922bfc..faca5b476 100644 --- a/packages/widgets/package.json +++ b/packages/widgets/package.json @@ -33,10 +33,12 @@ }, "prettier": "@homarr/prettier-config", "dependencies": { - "@homarr/ui": "workspace:^0.1.0", + "@homarr/common": "workspace:^0.1.0", + "@homarr/definitions": "workspace:^0.1.0", "@homarr/form": "workspace:^0.1.0", "@homarr/notifications": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0", + "@homarr/ui": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0" } } diff --git a/packages/widgets/src/_inputs/form.ts b/packages/widgets/src/_inputs/form.ts index 7fac3a611..c90b4d402 100644 --- a/packages/widgets/src/_inputs/form.ts +++ b/packages/widgets/src/_inputs/form.ts @@ -2,5 +2,7 @@ import { createFormContext } from "@homarr/form"; +import type { WidgetEditModalState } from "../modals/widget-edit-modal"; + export const [FormProvider, useFormContext, useForm] = - createFormContext>(); + createFormContext(); diff --git a/packages/widgets/src/_inputs/widget-multiselect-input.tsx b/packages/widgets/src/_inputs/widget-multiselect-input.tsx index c26c2782b..9d276da3a 100644 --- a/packages/widgets/src/_inputs/widget-multiselect-input.tsx +++ b/packages/widgets/src/_inputs/widget-multiselect-input.tsx @@ -19,7 +19,7 @@ export const WidgetMultiSelectInput = ({ label={t("label")} data={options.options as unknown as string[]} description={options.withDescription ? t("description") : undefined} - {...form.getInputProps(property)} + {...form.getInputProps(`options.${property}`)} /> ); }; diff --git a/packages/widgets/src/_inputs/widget-number-input.tsx b/packages/widgets/src/_inputs/widget-number-input.tsx index 6a09c00a3..58e7dafae 100644 --- a/packages/widgets/src/_inputs/widget-number-input.tsx +++ b/packages/widgets/src/_inputs/widget-number-input.tsx @@ -21,7 +21,7 @@ export const WidgetNumberInput = ({ min={options.validate.minValue ?? undefined} max={options.validate.maxValue ?? undefined} step={options.step} - {...form.getInputProps(property)} + {...form.getInputProps(`options.${property}`)} /> ); }; diff --git a/packages/widgets/src/_inputs/widget-select-input.tsx b/packages/widgets/src/_inputs/widget-select-input.tsx index f1cbfb390..85e34c558 100644 --- a/packages/widgets/src/_inputs/widget-select-input.tsx +++ b/packages/widgets/src/_inputs/widget-select-input.tsx @@ -19,7 +19,7 @@ export const WidgetSelectInput = ({ label={t("label")} data={options.options as unknown as string[]} description={options.withDescription ? t("description") : undefined} - {...form.getInputProps(property)} + {...form.getInputProps(`options.${property}`)} /> ); }; diff --git a/packages/widgets/src/_inputs/widget-slider-input.tsx b/packages/widgets/src/_inputs/widget-slider-input.tsx index db82db75a..31ef00fab 100644 --- a/packages/widgets/src/_inputs/widget-slider-input.tsx +++ b/packages/widgets/src/_inputs/widget-slider-input.tsx @@ -23,7 +23,7 @@ export const WidgetSliderInput = ({ min={options.validate.minValue ?? undefined} max={options.validate.maxValue ?? undefined} step={options.step} - {...form.getInputProps(property)} + {...form.getInputProps(`options.${property}`)} /> ); diff --git a/packages/widgets/src/_inputs/widget-switch-input.tsx b/packages/widgets/src/_inputs/widget-switch-input.tsx index 7f76d4f10..d09906f9e 100644 --- a/packages/widgets/src/_inputs/widget-switch-input.tsx +++ b/packages/widgets/src/_inputs/widget-switch-input.tsx @@ -18,7 +18,7 @@ export const WidgetSwitchInput = ({ ); }; diff --git a/packages/widgets/src/_inputs/widget-text-input.tsx b/packages/widgets/src/_inputs/widget-text-input.tsx index c8deab749..af27560b1 100644 --- a/packages/widgets/src/_inputs/widget-text-input.tsx +++ b/packages/widgets/src/_inputs/widget-text-input.tsx @@ -18,7 +18,7 @@ export const WidgetTextInput = ({ ); }; diff --git a/packages/widgets/src/clock/component.tsx b/packages/widgets/src/clock/component.tsx index ff4ae4d79..c8f3b7887 100644 --- a/packages/widgets/src/clock/component.tsx +++ b/packages/widgets/src/clock/component.tsx @@ -1,7 +1,8 @@ import type { WidgetComponentProps } from "../definition"; export default function ClockWidget({ - options, + options: _options, + integrations: _integrations, }: WidgetComponentProps<"clock">) { - return
{JSON.stringify(options)}
; + return
CLOCK
; } diff --git a/packages/widgets/src/clock/index.ts b/packages/widgets/src/clock/index.ts index 428cd492b..09337d32f 100644 --- a/packages/widgets/src/clock/index.ts +++ b/packages/widgets/src/clock/index.ts @@ -5,6 +5,7 @@ import { opt } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("clock", { icon: IconClock, + supportedIntegrations: ["adGuardHome", "piHole"], options: opt.from( (fac) => ({ is24HourFormat: fac.switch({ diff --git a/packages/widgets/src/definition.ts b/packages/widgets/src/definition.ts index d0b1eaa0d..25b9303db 100644 --- a/packages/widgets/src/definition.ts +++ b/packages/widgets/src/definition.ts @@ -1,5 +1,6 @@ import type { LoaderComponent } from "next/dynamic"; +import type { IntegrationKind } from "@homarr/definitions"; import type { TablerIconsProps } from "@homarr/ui"; import type { WidgetImports, WidgetSort } from "."; @@ -7,6 +8,7 @@ import type { inferOptionsFromDefinition, WidgetOptionsRecord, } from "./options"; +import type { IntegrationSelectOption } from "./widget-integration-select"; export const createWidgetDefinition = < TSort extends WidgetSort, @@ -28,12 +30,31 @@ export const createWidgetDefinition = < interface Definition { icon: (props: TablerIconsProps) => JSX.Element; + supportedIntegrations?: IntegrationKind[]; options: WidgetOptionsRecord; } export interface WidgetComponentProps { options: inferOptionsFromDefinition>; - integrations: unknown[]; + integrations: inferIntegrationsFromDefinition< + WidgetImports[TSort]["definition"] + >; +} + +type inferIntegrationsFromDefinition = + TDefinition extends { + supportedIntegrations: infer TSupportedIntegrations; + } // check if definition has supportedIntegrations + ? TSupportedIntegrations extends IntegrationKind[] // check if supportedIntegrations is an array of IntegrationKind + ? IntegrationSelectOptionFor[] // if so, return an array of IntegrationSelectOptionFor + : IntegrationSelectOption[] // otherwise, return an array of IntegrationSelectOption without specifying the kind + : IntegrationSelectOption[]; + +interface IntegrationSelectOptionFor { + id: string; + name: string; + url: string; + kind: TIntegration[number]; } export type WidgetOptionsRecordOf = diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index 882ea960f..5c9f64038 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -8,7 +8,9 @@ import type { WidgetComponentProps } from "./definition"; import type { WidgetImportRecord } from "./import"; import * as weather from "./weather"; -export { WidgetEditModal } from "./WidgetEditModal"; +export { reduceWidgetOptionsWithDefaultValues } from "./options"; + +export { WidgetEditModal } from "./modals/widget-edit-modal"; export const widgetSorts = ["clock", "weather"] as const; diff --git a/packages/widgets/src/WidgetEditModal.tsx b/packages/widgets/src/modals/widget-edit-modal.tsx similarity index 59% rename from packages/widgets/src/WidgetEditModal.tsx rename to packages/widgets/src/modals/widget-edit-modal.tsx index faf44a308..09355eb29 100644 --- a/packages/widgets/src/WidgetEditModal.tsx +++ b/packages/widgets/src/modals/widget-edit-modal.tsx @@ -3,27 +3,35 @@ import type { Dispatch, SetStateAction } from "react"; import type { ManagedModal } from "mantine-modal-manager"; +import { useScopedI18n } from "@homarr/translation/client"; import { Button, Group, Stack } from "@homarr/ui"; -import type { WidgetSort } from "."; -import { getInputForType } from "./_inputs"; -import { FormProvider, useForm } from "./_inputs/form"; -import type { WidgetOptionsRecordOf } from "./definition"; -import type { WidgetOptionDefinition } from "./options"; +import type { WidgetSort } from ".."; +import { getInputForType } from "../_inputs"; +import { FormProvider, useForm } from "../_inputs/form"; +import type { WidgetOptionsRecordOf } from "../definition"; +import type { WidgetOptionDefinition } from "../options"; +import { WidgetIntegrationSelect } from "../widget-integration-select"; +import type { IntegrationSelectOption } from "../widget-integration-select"; + +export interface WidgetEditModalState { + options: Record; + integrations: string[]; +} interface ModalProps { sort: TSort; - state: [ - Record, - Dispatch>>, - ]; + state: [WidgetEditModalState, Dispatch>]; definition: WidgetOptionsRecordOf; + integrationData: IntegrationSelectOption[]; + integrationSupport: boolean; } export const WidgetEditModal: ManagedModal> = ({ actions, innerProps, }) => { + const t = useScopedI18n("widget.editModal"); const [value, setValue] = innerProps.state; const form = useForm({ initialValues: value, @@ -38,6 +46,13 @@ export const WidgetEditModal: ManagedModal> = ({ > + {innerProps.integrationSupport && ( + + )} {Object.entries(innerProps.definition).map( ([key, value]: [string, WidgetOptionDefinition]) => { const Input = getInputForType(value.type); diff --git a/packages/widgets/src/options.ts b/packages/widgets/src/options.ts index 85c6c7479..ec46bc2c6 100644 --- a/packages/widgets/src/options.ts +++ b/packages/widgets/src/options.ts @@ -1,3 +1,4 @@ +import { objectEntries } from "@homarr/common"; import type { z } from "@homarr/validation"; interface CommonInput { @@ -140,3 +141,15 @@ const createOptions = ( export const opt = { from: createOptions, }; + +export const reduceWidgetOptionsWithDefaultValues = ( + optionsDefinition: Record, + currentValue: Record = {}, +) => + objectEntries(optionsDefinition).reduce( + (prev, [key, value]) => ({ + ...prev, + [key]: currentValue[key] ?? value.defaultValue, + }), + {} as Record, + ); diff --git a/packages/widgets/src/weather/component.tsx b/packages/widgets/src/weather/component.tsx index 999805d85..6bd0945b3 100644 --- a/packages/widgets/src/weather/component.tsx +++ b/packages/widgets/src/weather/component.tsx @@ -1,7 +1,7 @@ import type { WidgetComponentProps } from "../definition"; export default function WeatherWidget({ - options, + options: _options, }: WidgetComponentProps<"weather">) { - return
{JSON.stringify(options)}
; + return
WEATHER
; } diff --git a/packages/widgets/src/widget-integration-select.module.css b/packages/widgets/src/widget-integration-select.module.css new file mode 100644 index 000000000..7e1da6c97 --- /dev/null +++ b/packages/widgets/src/widget-integration-select.module.css @@ -0,0 +1,11 @@ +.pill { + cursor: default; + background-color: light-dark( + var(--mantine-color-white), + var(--mantine-color-dark-7) + ); + border: rem(1px) solid + light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-7)); + padding-left: var(--mantine-spacing-xs); + border-radius: var(--mantine-radius-xl); +} diff --git a/packages/widgets/src/widget-integration-select.tsx b/packages/widgets/src/widget-integration-select.tsx new file mode 100644 index 000000000..2b70ec1f4 --- /dev/null +++ b/packages/widgets/src/widget-integration-select.tsx @@ -0,0 +1,162 @@ +"use client"; + +import type { FocusEventHandler } from "react"; + +import type { IntegrationKind } from "@homarr/definitions"; +import { getIconUrl } from "@homarr/definitions"; +import { useI18n } from "@homarr/translation/client"; +import { + Avatar, + CheckIcon, + CloseButton, + Combobox, + Group, + Input, + Pill, + PillsInput, + Stack, + Text, + useCombobox, +} from "@homarr/ui"; + +import classes from "./widget-integration-select.module.css"; + +interface WidgetIntegrationSelectProps { + label: string; + onChange: (value: string[]) => void; + value?: string[]; + error?: string; + onFocus?: FocusEventHandler; + onBlur?: FocusEventHandler; + + data: IntegrationSelectOption[]; +} +export const WidgetIntegrationSelect = ({ + data, + onChange, + value: valueProp, + ...props +}: WidgetIntegrationSelectProps) => { + const t = useI18n(); + const multiSelectValues = valueProp ?? []; + + const combobox = useCombobox({ + onDropdownClose: () => combobox.resetSelectedOption(), + onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"), + }); + + const handleValueSelect = (selectedValue: string) => + onChange( + multiSelectValues.includes(selectedValue) + ? multiSelectValues.filter((v) => v !== selectedValue) + : [...multiSelectValues, selectedValue], + ); + + const handleValueRemove = (val: string) => + onChange(multiSelectValues.filter((v) => v !== val)); + + const values = multiSelectValues.map((item) => ( + i.id === item)!} + onRemove={() => handleValueRemove(item)} + /> + )); + + const options = data.map((item) => { + return ( + + + {multiSelectValues.includes(item.id) ? : null} + + + + {item.name} + + {item.url} + + + + + + ); + }); + + return ( + + + combobox.toggleDropdown()} + {...props} + > + + {values.length > 0 ? ( + values + ) : ( + + {t("common.multiSelect.placeholder")} + + )} + + + combobox.closeDropdown()} + onKeyDown={(event) => { + if (event.key !== "Backspace") return; + + event.preventDefault(); + handleValueRemove( + multiSelectValues[multiSelectValues.length - 1]!, + ); + }} + /> + + + + + + + {options} + + + ); +}; + +export interface IntegrationSelectOption { + id: string; + name: string; + url: string; + kind: IntegrationKind; +} + +interface IntegrationPillProps { + option: IntegrationSelectOption; + onRemove: () => void; +} + +const IntegrationPill = ({ option, onRemove }: IntegrationPillProps) => ( + + + + {option.name} + + + +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8056e91e..187cb23dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -502,6 +502,12 @@ importers: packages/widgets: dependencies: + '@homarr/common': + specifier: workspace:^0.1.0 + version: link:../common + '@homarr/definitions': + specifier: workspace:^0.1.0 + version: link:../definitions '@homarr/form': specifier: workspace:^0.1.0 version: link:../form