From 49c0ebea6dad6f586fc61073f815a928db4b435b Mon Sep 17 00:00:00 2001 From: Manuel <30572287+manuel-rw@users.noreply.github.com> Date: Sat, 2 Nov 2024 18:44:36 +0100 Subject: [PATCH] feat: add bookmark widget (#964) * feat: add bookmark widget * fix: item component type issue, widget-ordered-object-list-input item component issue * feat: add button in items list * wip * wip: bookmark options dnd * wip: improve widget sortable item list * feat: add sortable item list input to widget edit modal * feat: implement bookmark widget * chore: address pull request feedback * fix: format issues * fix: lockfile not up to date * fix: import configuration missing and apps not imported * fix: bookmark items not sorted * feat: add flex layouts to bookmark widget * fix: deepsource issue * fix: add missing layout bookmarks old-import options mapping --------- Co-authored-by: Meier Lukas --- packages/api/src/router/app.ts | 11 +- packages/definitions/src/widget.ts | 1 + packages/old-import/src/import-apps.ts | 138 +++++++++-- packages/old-import/src/import-items.ts | 13 +- packages/old-import/src/index.ts | 19 +- .../src/widgets/definitions/bookmark.ts | 2 + .../src/widgets/definitions/index.ts | 1 + packages/old-import/src/widgets/options.ts | 21 +- packages/translation/src/lang/en.ts | 33 +++ packages/widgets/package.json | 2 + packages/widgets/src/_inputs/common.tsx | 1 + packages/widgets/src/_inputs/index.ts | 2 + .../widget-sortable-item-list-input.tsx | 233 ++++++++++++++++++ .../src/bookmarks/app-select-modal.tsx | 114 +++++++++ .../widgets/src/bookmarks/bookmark.module.css | 3 + packages/widgets/src/bookmarks/component.tsx | 160 ++++++++++++ packages/widgets/src/bookmarks/index.tsx | 67 +++++ packages/widgets/src/index.tsx | 2 + .../widgets/src/modals/widget-edit-modal.tsx | 10 +- packages/widgets/src/options.ts | 35 ++- pnpm-lock.yaml | 53 ++++ 21 files changed, 889 insertions(+), 32 deletions(-) create mode 100644 packages/widgets/src/_inputs/widget-sortable-item-list-input.tsx create mode 100644 packages/widgets/src/bookmarks/app-select-modal.tsx create mode 100644 packages/widgets/src/bookmarks/bookmark.module.css create mode 100644 packages/widgets/src/bookmarks/component.tsx create mode 100644 packages/widgets/src/bookmarks/index.tsx diff --git a/packages/api/src/router/app.ts b/packages/api/src/router/app.ts index 1987671fd..1ce137b72 100644 --- a/packages/api/src/router/app.ts +++ b/packages/api/src/router/app.ts @@ -1,6 +1,6 @@ import { TRPCError } from "@trpc/server"; -import { asc, createId, eq, like } from "@homarr/db"; +import { asc, createId, eq, inArray, like } from "@homarr/db"; import { apps } from "@homarr/db/schema/sqlite"; import { validation, z } from "@homarr/validation"; @@ -55,6 +55,8 @@ export const appRouter = createTRPCRouter({ name: z.string(), id: z.string(), iconUrl: z.string(), + description: z.string().nullable(), + href: z.string().nullable(), }), ), ) @@ -72,6 +74,8 @@ export const appRouter = createTRPCRouter({ id: true, name: true, iconUrl: true, + description: true, + href: true, }, orderBy: asc(apps.name), }); @@ -102,6 +106,11 @@ export const appRouter = createTRPCRouter({ return app; }), + byIds: publicProcedure.input(z.array(z.string())).query(async ({ ctx, input }) => { + return await ctx.db.query.apps.findMany({ + where: inArray(apps.id, input), + }); + }), create: protectedProcedure .input(validation.app.manage) .output(z.void()) diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index 637f20e3f..0f68c6577 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -15,6 +15,7 @@ export const widgetKinds = [ "mediaRequests-requestList", "mediaRequests-requestStats", "rssFeed", + "bookmarks", "indexerManager", "healthMonitoring", ] as const; diff --git a/packages/old-import/src/import-apps.ts b/packages/old-import/src/import-apps.ts index 4ef2b2771..342ba682f 100644 --- a/packages/old-import/src/import-apps.ts +++ b/packages/old-import/src/import-apps.ts @@ -1,49 +1,57 @@ import { createId, inArray } from "@homarr/db"; -import type { Database, InferInsertModel } from "@homarr/db"; +import type { Database, InferInsertModel, InferSelectModel } from "@homarr/db"; import { apps as appsTable } from "@homarr/db/schema/sqlite"; import { logger } from "@homarr/log"; import type { OldmarrApp } from "@homarr/old-schema"; +import type { BookmarkApp } from "./widgets/definitions/bookmark"; + +type DbAppWithoutId = Omit, "id">; + +interface AppMapping extends DbAppWithoutId { + ids: string[]; + newId: string; + exists: boolean; +} + export const insertAppsAsync = async ( db: Database, apps: OldmarrApp[], + bookmarkApps: BookmarkApp[], distinctAppsByHref: boolean, configName: string, ) => { logger.info( `Importing old homarr apps configuration=${configName} distinctAppsByHref=${distinctAppsByHref} apps=${apps.length}`, ); + const existingAppsWithHref = distinctAppsByHref ? await db.query.apps.findMany({ - where: inArray(appsTable.href, [...new Set(apps.map((app) => app.url))]), + where: inArray(appsTable.href, [ + ...new Set(apps.map((app) => app.url).concat(bookmarkApps.map((app) => app.href))), + ]), }) : []; logger.debug(`Found existing apps with href count=${existingAppsWithHref.length}`); - const mappedApps = apps.map((app) => ({ - // Use id of existing app when it has the same href and distinctAppsByHref is true - newId: distinctAppsByHref - ? (existingAppsWithHref.find( - (existingApp) => - existingApp.href === (app.behaviour.externalUrl === "" ? app.url : app.behaviour.externalUrl) && - existingApp.name === app.name && - existingApp.iconUrl === app.appearance.iconUrl, - )?.id ?? createId()) - : createId(), - ...app, - })); + // Generate mappings for all apps from old to new ids + const appMappings: AppMapping[] = []; + addMappingFor(apps, appMappings, existingAppsWithHref, convertApp); + addMappingFor(bookmarkApps, appMappings, existingAppsWithHref, convertBookmarkApp); - const appsToCreate = mappedApps - .filter((app) => !existingAppsWithHref.some((existingApp) => existingApp.id === app.newId)) + logger.debug(`Mapping apps count=${appMappings.length}`); + + const appsToCreate = appMappings + .filter((app) => !app.exists) .map( (app) => ({ id: app.newId, name: app.name, - iconUrl: app.appearance.iconUrl, - href: app.behaviour.externalUrl === "" ? app.url : app.behaviour.externalUrl, - description: app.behaviour.tooltipDescription, + iconUrl: app.iconUrl, + href: app.href, + description: app.description, }) satisfies InferInsertModel, ); @@ -55,5 +63,95 @@ export const insertAppsAsync = async ( logger.info(`Imported apps count=${appsToCreate.length}`); - return mappedApps; + // Generates a map from old key to new key for all apps + return new Map( + appMappings + .map((app) => app.ids.map((id) => ({ id, newId: app.newId }))) + .flat() + .map(({ id, newId }) => [id, newId]), + ); }; + +/** + * Creates a callback to be used in a find method that compares the old app with the new app + * @param app either an oldmarr app or a bookmark app + * @param convertApp a function that converts the app to a new app + * @returns a callback that compares the old app with the new app and returns true if they are the same + */ +const createFindCallback = ( + app: TApp, + convertApp: (app: TApp) => DbAppWithoutId, +) => { + const oldApp = convertApp(app); + + return (dbApp: DbAppWithoutId) => + oldApp.href === dbApp.href && + oldApp.name === dbApp.name && + oldApp.iconUrl === dbApp.iconUrl && + oldApp.description === dbApp.description; +}; + +/** + * Adds mappings for the given apps to the appMappings array + * @param apps apps to add mappings for + * @param appMappings existing app mappings + * @param existingAppsWithHref existing apps with href + * @param convertApp a function that converts the app to a new app + */ +const addMappingFor = ( + apps: TApp[], + appMappings: AppMapping[], + existingAppsWithHref: InferSelectModel[], + convertApp: (app: TApp) => DbAppWithoutId, +) => { + for (const app of apps) { + const previous = appMappings.find(createFindCallback(app, convertApp)); + if (previous) { + previous.ids.push(app.id); + continue; + } + + const existing = existingAppsWithHref.find(createFindCallback(app, convertApp)); + if (existing) { + appMappings.push({ + ids: [app.id], + newId: existing.id, + name: existing.name, + href: existing.href, + iconUrl: existing.iconUrl, + description: existing.description, + exists: true, + }); + continue; + } + + appMappings.push({ + ids: [app.id], + newId: createId(), + ...convertApp(app), + exists: false, + }); + } +}; + +/** + * Converts an oldmarr app to a new app + * @param app oldmarr app + * @returns new app + */ +const convertApp = (app: OldmarrApp): DbAppWithoutId => ({ + name: app.name, + href: app.behaviour.externalUrl === "" ? app.url : app.behaviour.externalUrl, + iconUrl: app.appearance.iconUrl, + description: app.behaviour.tooltipDescription ?? null, +}); + +/** + * Converts a bookmark app to a new app + * @param app bookmark app + * @returns new app + */ +const convertBookmarkApp = (app: BookmarkApp): DbAppWithoutId => ({ + ...app, + description: null, +}); diff --git a/packages/old-import/src/import-items.ts b/packages/old-import/src/import-items.ts index 69a98a2c5..fab64c88e 100644 --- a/packages/old-import/src/import-items.ts +++ b/packages/old-import/src/import-items.ts @@ -15,11 +15,12 @@ import { mapOptions } from "./widgets/options"; export const insertItemsAsync = async ( db: Database, widgets: OldmarrWidget[], - mappedApps: (OldmarrApp & { newId: string })[], + apps: OldmarrApp[], + appsMap: Map, sectionIdMaps: Map, configuration: OldmarrImportConfiguration, ) => { - logger.info(`Importing old homarr items widgets=${widgets.length} apps=${mappedApps.length}`); + logger.info(`Importing old homarr items widgets=${widgets.length} apps=${apps.length}`); for (const widget of widgets) { // All items should have been moved to the last wrapper @@ -54,13 +55,13 @@ export const insertItemsAsync = async ( xOffset: screenSizeShape.location.x, yOffset: screenSizeShape.location.y, kind, - options: SuperJSON.stringify(mapOptions(kind, widget.properties)), + options: SuperJSON.stringify(mapOptions(kind, widget.properties, appsMap)), }); logger.debug(`Inserted widget id=${widget.id} sectionId=${sectionId}`); } - for (const app of mappedApps) { + for (const app of apps) { // All items should have been moved to the last wrapper if (app.area.type === "sidebar") { continue; @@ -85,7 +86,9 @@ export const insertItemsAsync = async ( yOffset: screenSizeShape.location.y, kind: "app", options: SuperJSON.stringify({ - appId: app.newId, + // it's safe to assume that the app exists in the map + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + appId: appsMap.get(app.id)!, openInNewTab: app.behaviour.isOpeningNewTab, pingEnabled: app.network.enabledStatusChecker, showDescriptionTooltip: app.behaviour.tooltipDescription !== "", diff --git a/packages/old-import/src/index.ts b/packages/old-import/src/index.ts index b1ed0caaa..aa5ad882a 100644 --- a/packages/old-import/src/index.ts +++ b/packages/old-import/src/index.ts @@ -9,12 +9,24 @@ import { OldHomarrImportError, OldHomarrScreenSizeError } from "./import-error"; import { insertItemsAsync } from "./import-items"; import { insertSectionsAsync } from "./import-sections"; import { moveWidgetsAndAppsIfMerge } from "./move-widgets-and-apps-merge"; +import type { BookmarkApp } from "./widgets/definitions/bookmark"; export const importAsync = async (db: Database, old: OldmarrConfig, configuration: OldmarrImportConfiguration) => { + const bookmarkApps = old.widgets + .filter((widget) => widget.type === "bookmark") + .map((widget) => widget.properties.items) + .flat() as BookmarkApp[]; + if (configuration.onlyImportApps) { await db .transaction(async (trasaction) => { - await insertAppsAsync(trasaction, old.apps, configuration.distinctAppsByHref, old.configProperties.name); + await insertAppsAsync( + trasaction, + old.apps, + bookmarkApps, + configuration.distinctAppsByHref, + old.configProperties.name, + ); }) .catch((error) => { throw new OldHomarrImportError(old, error); @@ -29,13 +41,14 @@ export const importAsync = async (db: Database, old: OldmarrConfig, configuratio const boardId = await insertBoardAsync(trasaction, old, configuration); const sectionIdMaps = await insertSectionsAsync(trasaction, categories, wrappers, boardId); - const mappedApps = await insertAppsAsync( + const appsMap = await insertAppsAsync( trasaction, apps, + bookmarkApps, configuration.distinctAppsByHref, old.configProperties.name, ); - await insertItemsAsync(trasaction, widgets, mappedApps, sectionIdMaps, configuration); + await insertItemsAsync(trasaction, widgets, apps, appsMap, sectionIdMaps, configuration); }) .catch((error) => { if (error instanceof OldHomarrScreenSizeError) { diff --git a/packages/old-import/src/widgets/definitions/bookmark.ts b/packages/old-import/src/widgets/definitions/bookmark.ts index 178185777..f97d2ba3b 100644 --- a/packages/old-import/src/widgets/definitions/bookmark.ts +++ b/packages/old-import/src/widgets/definitions/bookmark.ts @@ -16,3 +16,5 @@ export type OldmarrBookmarkDefinition = CommonOldmarrWidgetDefinition< layout: "autoGrid" | "horizontal" | "vertical"; } >; + +export type BookmarkApp = OldmarrBookmarkDefinition["options"]["items"][number]; diff --git a/packages/old-import/src/widgets/definitions/index.ts b/packages/old-import/src/widgets/definitions/index.ts index e74c5c98c..d574e0a37 100644 --- a/packages/old-import/src/widgets/definitions/index.ts +++ b/packages/old-import/src/widgets/definitions/index.ts @@ -66,6 +66,7 @@ export const widgetKindMapping = { "mediaRequests-requestList": "media-requests-list", "mediaRequests-requestStats": "media-requests-stats", indexerManager: "indexer-manager", + bookmarks: "bookmark", healthMonitoring: "health-monitoring", } satisfies Record; // Use null for widgets that did not exist in oldmarr diff --git a/packages/old-import/src/widgets/options.ts b/packages/old-import/src/widgets/options.ts index badf31981..2b688aadf 100644 --- a/packages/old-import/src/widgets/options.ts +++ b/packages/old-import/src/widgets/options.ts @@ -13,6 +13,7 @@ type OptionMapping = { : { [OptionsKey in keyof WidgetComponentProps["options"]]: ( oldOptions: Extract["options"], + appsMap: Map, ) => WidgetComponentProps["options"][OptionsKey] | undefined; }; }; @@ -22,6 +23,22 @@ const optionMapping: OptionMapping = { linksTargetNewTab: (oldOptions) => oldOptions.openInNewTab, }, "mediaRequests-requestStats": {}, + bookmarks: { + title: (oldOptions) => oldOptions.name, + // It's safe to assume that the app exists, because the app is always created before the widget + // And the mapping is created in insertAppsAsync + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + items: (oldOptions, appsMap) => oldOptions.items.map((item) => appsMap.get(item.id)!), + layout: (oldOptions) => { + const mappedLayouts: Record["options"]["layout"]> = { + autoGrid: "grid", + horizontal: "row", + vertical: "column", + }; + + return mappedLayouts[oldOptions.layout]; + }, + }, calendar: { releaseType: (oldOptions) => [oldOptions.radarrReleaseType], filterFutureMonths: () => undefined, @@ -118,11 +135,13 @@ const optionMapping: OptionMapping = { * Maps the oldmarr options to the newmarr options * @param kind item kind to map * @param oldOptions oldmarr options for this item + * @param appsMap map of old app ids to new app ids * @returns newmarr options for this item or null if the item did not exist in oldmarr */ export const mapOptions = ( kind: K, oldOptions: Extract["options"], + appsMap: Map, ) => { logger.debug(`Mapping old homarr options for widget kind=${kind} options=${JSON.stringify(oldOptions)}`); if (optionMapping[kind] === null) { @@ -132,7 +151,7 @@ export const mapOptions = ( const mapping = optionMapping[kind]; return objectEntries(mapping).reduce( (acc, [key, value]) => { - const newValue = value(oldOptions as never); + const newValue = value(oldOptions as never, appsMap); logger.debug(`Mapping old homarr option kind=${kind} key=${key as string} newValue=${newValue as string}`); if (newValue !== undefined) { acc[key as string] = newValue; diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts index 0a87bbb6b..a697fd8c6 100644 --- a/packages/translation/src/lang/en.ts +++ b/packages/translation/src/lang/en.ts @@ -386,6 +386,12 @@ export default { label: "Url", }, }, + action: { + select: { + label: "Select app", + notFound: "No app found", + }, + }, }, integration: { page: { @@ -838,6 +844,33 @@ export default { }, }, }, + bookmarks: { + name: "Bookmarks", + description: "Displays multiple app links", + option: { + title: { + label: "Title", + }, + layout: { + label: "Layout", + option: { + row: { + label: "Horizontal", + }, + column: { + label: "Vertical", + }, + grid: { + label: "Grid", + }, + }, + }, + items: { + label: "Bookmarks", + add: "Add bookmark", + }, + }, + }, dnsHoleSummary: { name: "DNS Hole Summary", description: "Displays the summary of your DNS Hole", diff --git a/packages/widgets/package.json b/packages/widgets/package.json index d87eb102a..c02d61d27 100644 --- a/packages/widgets/package.json +++ b/packages/widgets/package.json @@ -23,6 +23,8 @@ }, "prettier": "@homarr/prettier-config", "dependencies": { + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/sortable": "^8.0.0", "@extractus/feed-extractor": "^7.1.3", "@homarr/api": "workspace:^0.1.0", "@homarr/auth": "workspace:^0.1.0", diff --git a/packages/widgets/src/_inputs/common.tsx b/packages/widgets/src/_inputs/common.tsx index 17d03aecf..5c7769eb1 100644 --- a/packages/widgets/src/_inputs/common.tsx +++ b/packages/widgets/src/_inputs/common.tsx @@ -7,6 +7,7 @@ export interface CommonWidgetInputProps { kind: WidgetKind; property: string; options: Omit, "defaultValue" | "type">; + initialOptions: Record; } type UseWidgetInputTranslationReturnType = (key: "label" | "description") => string; diff --git a/packages/widgets/src/_inputs/index.ts b/packages/widgets/src/_inputs/index.ts index 09303c8b7..d675e5c1c 100644 --- a/packages/widgets/src/_inputs/index.ts +++ b/packages/widgets/src/_inputs/index.ts @@ -6,6 +6,7 @@ import { WidgetMultiSelectInput } from "./widget-multiselect-input"; import { WidgetNumberInput } from "./widget-number-input"; import { WidgetSelectInput } from "./widget-select-input"; import { WidgetSliderInput } from "./widget-slider-input"; +import { WidgetSortedItemListInput } from "./widget-sortable-item-list-input"; import { WidgetSwitchInput } from "./widget-switch-input"; import { WidgetTextInput } from "./widget-text-input"; @@ -19,6 +20,7 @@ const mapping = { slider: WidgetSliderInput, switch: WidgetSwitchInput, app: WidgetAppInput, + sortableItemList: WidgetSortedItemListInput, } satisfies Record; export const getInputForType = (type: TType) => { diff --git a/packages/widgets/src/_inputs/widget-sortable-item-list-input.tsx b/packages/widgets/src/_inputs/widget-sortable-item-list-input.tsx new file mode 100644 index 000000000..9f6c36b78 --- /dev/null +++ b/packages/widgets/src/_inputs/widget-sortable-item-list-input.tsx @@ -0,0 +1,233 @@ +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { UniqueIdentifier } from "@dnd-kit/core"; +import { + closestCenter, + DndContext, + KeyboardSensor, + MouseSensor, + TouchSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import type { ActionIconProps } from "@mantine/core"; +import { ActionIcon, Card, Center, Fieldset, Loader, Stack } from "@mantine/core"; +import { IconGripHorizontal } from "@tabler/icons-react"; + +import { useWidgetInputTranslation } from "./common"; +import type { CommonWidgetInputProps } from "./common"; +import { useFormContext } from "./form"; + +export const WidgetSortedItemListInput = ({ + property, + options, + initialOptions, + kind, +}: CommonWidgetInputProps<"sortableItemList">) => { + const t = useWidgetInputTranslation(kind, property); + const form = useFormContext(); + const initialValues = useMemo(() => initialOptions[property] as TOptionValue[], [initialOptions, property]); + const values = form.values.options[property] as TOptionValue[]; + const { data, isLoading, error } = options.useData(initialValues); + const dataMap = useMemo( + () => new Map(data?.map((item) => [options.uniqueIdentifier(item), item as TItem])), + [data, options], + ); + const [tempMap, setTempMap] = useState>(new Map()); + + const [activeId, setActiveId] = useState(null); + const sensors = useSensors( + useSensor(MouseSensor), + useSensor(TouchSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + const isFirstAnnouncement = useRef(true); + const getIndex = (id: TOptionValue) => values.indexOf(id); + const activeIndex = activeId ? getIndex(activeId) : -1; + + useEffect(() => { + if (!activeId) { + isFirstAnnouncement.current = true; + } + }, [activeId]); + + const getItem = useCallback( + (id: TOptionValue) => { + if (!tempMap.has(id)) { + return dataMap.get(id); + } + + return tempMap.get(id); + }, + [tempMap, dataMap], + ); + + const updateItems = (callback: (prev: TOptionValue[]) => TOptionValue[]) => { + form.setFieldValue(`options.${property}`, callback); + }; + + const addItem = (item: TItem) => { + setTempMap((prev) => { + prev.set(options.uniqueIdentifier(item) as TOptionValue, item); + return prev; + }); + updateItems((values) => [...values, options.uniqueIdentifier(item) as TOptionValue]); + }; + + return ( +
+ + + + { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!active) { + return; + } + + setActiveId(active.id as TOptionValue); + }} + onDragEnd={({ over }) => { + setActiveId(null); + + if (over) { + const overIndex = getIndex(over.id as TOptionValue); + if (activeIndex !== overIndex) { + updateItems((items) => arrayMove(items, activeIndex, overIndex)); + } + } + }} + onDragCancel={() => setActiveId(null)} + > + + + <> + {values.map((value, index) => { + const item = getItem(value); + const removeItem = () => { + form.setValues((previous) => { + const previousValues = previous.options?.[property] as TOptionValue[]; + return { + ...previous, + options: { + ...previous.options, + [property]: previousValues.filter((id) => id !== value), + }, + }; + }); + }; + + if (!item) { + return null; + } + + return ( + + ); + })} + {isLoading && ( +
+ +
+ )} + {error &&
{JSON.stringify(error)}
} + +
+
+
+
+
+ ); +}; + +interface ItemProps { + id: TOptionValue; + item: TItem; + index: number; + removeItem: () => void; + options: CommonWidgetInputProps<"sortableItemList">["options"]; +} + +const Item = ({ + id, + index, + item, + removeItem, + options, +}: ItemProps) => { + const { attributes, isDragging, listeners, setNodeRef, setActivatorNodeRef, transform, transition } = useSortable({ + id, + }); + + const Handle = (props: Partial) => { + return ( + + + + ); + }; + + return ( + + + + ); +}; + +const MemoizedItem = memo(Item); diff --git a/packages/widgets/src/bookmarks/app-select-modal.tsx b/packages/widgets/src/bookmarks/app-select-modal.tsx new file mode 100644 index 000000000..b99751751 --- /dev/null +++ b/packages/widgets/src/bookmarks/app-select-modal.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { memo, useState } from "react"; +import type { SelectProps } from "@mantine/core"; +import { Button, Group, Loader, Select, Stack } from "@mantine/core"; +import { IconCheck } from "@tabler/icons-react"; + +import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; +import { useForm } from "@homarr/form"; +import { createModal } from "@homarr/modals"; +import { useI18n } from "@homarr/translation/client"; + +interface InnerProps { + presentAppIds: string[]; + onSelect: (props: RouterOutputs["app"]["selectable"][number]) => void | Promise; + confirmLabel?: string; +} + +interface AppSelectFormType { + id: string; +} + +export const AppSelectModal = createModal(({ actions, innerProps }) => { + const t = useI18n(); + const { data: apps, isPending } = clientApi.app.selectable.useQuery(); + const [loading, setLoading] = useState(false); + const form = useForm(); + const handleSubmitAsync = async (values: AppSelectFormType) => { + const currentApp = apps?.find((app) => app.id === values.id); + if (!currentApp) return; + setLoading(true); + await innerProps.onSelect(currentApp); + + setLoading(false); + actions.closeModal(); + }; + + const confirmLabel = innerProps.confirmLabel ?? t("common.action.add"); + const currentApp = apps?.find((app) => app.id === form.values.id); + + return ( +
void handleSubmitAsync(values))}> + + ; + return ( + + ); })}