mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-27 17:00:54 +01:00
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 <meierschlumpf@gmail.com>
This commit is contained in:
@@ -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())
|
||||
|
||||
@@ -15,6 +15,7 @@ export const widgetKinds = [
|
||||
"mediaRequests-requestList",
|
||||
"mediaRequests-requestStats",
|
||||
"rssFeed",
|
||||
"bookmarks",
|
||||
"indexerManager",
|
||||
"healthMonitoring",
|
||||
] as const;
|
||||
|
||||
@@ -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<InferSelectModel<typeof appsTable>, "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<typeof appsTable>,
|
||||
);
|
||||
|
||||
@@ -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 = <TApp extends OldmarrApp | BookmarkApp>(
|
||||
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 = <TApp extends OldmarrApp | BookmarkApp>(
|
||||
apps: TApp[],
|
||||
appMappings: AppMapping[],
|
||||
existingAppsWithHref: InferSelectModel<typeof appsTable>[],
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -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<string, string>,
|
||||
sectionIdMaps: Map<string, string>,
|
||||
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 !== "",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -16,3 +16,5 @@ export type OldmarrBookmarkDefinition = CommonOldmarrWidgetDefinition<
|
||||
layout: "autoGrid" | "horizontal" | "vertical";
|
||||
}
|
||||
>;
|
||||
|
||||
export type BookmarkApp = OldmarrBookmarkDefinition["options"]["items"][number];
|
||||
|
||||
@@ -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<WidgetKind, OldmarrWidgetDefinitions["id"] | null>;
|
||||
// Use null for widgets that did not exist in oldmarr
|
||||
|
||||
@@ -13,6 +13,7 @@ type OptionMapping = {
|
||||
: {
|
||||
[OptionsKey in keyof WidgetComponentProps<WidgetKey>["options"]]: (
|
||||
oldOptions: Extract<OldmarrWidgetDefinitions, { id: WidgetMapping[WidgetKey] }>["options"],
|
||||
appsMap: Map<string, string>,
|
||||
) => WidgetComponentProps<WidgetKey>["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<typeof oldOptions.layout, WidgetComponentProps<"bookmarks">["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 = <K extends WidgetKind>(
|
||||
kind: K,
|
||||
oldOptions: Extract<OldmarrWidgetDefinitions, { id: WidgetMapping[K] }>["options"],
|
||||
appsMap: Map<string, string>,
|
||||
) => {
|
||||
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 = <K extends WidgetKind>(
|
||||
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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface CommonWidgetInputProps<TKey extends WidgetOptionType> {
|
||||
kind: WidgetKind;
|
||||
property: string;
|
||||
options: Omit<WidgetOptionOfType<TKey>, "defaultValue" | "type">;
|
||||
initialOptions: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type UseWidgetInputTranslationReturnType = (key: "label" | "description") => string;
|
||||
|
||||
@@ -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<WidgetOptionType, unknown>;
|
||||
|
||||
export const getInputForType = <TType extends WidgetOptionType>(type: TType) => {
|
||||
|
||||
233
packages/widgets/src/_inputs/widget-sortable-item-list-input.tsx
Normal file
233
packages/widgets/src/_inputs/widget-sortable-item-list-input.tsx
Normal file
@@ -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 = <TItem, TOptionValue extends UniqueIdentifier>({
|
||||
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<Map<TOptionValue, TItem>>(new Map());
|
||||
|
||||
const [activeId, setActiveId] = useState<TOptionValue | null>(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 (
|
||||
<Fieldset legend={t("label")}>
|
||||
<Stack>
|
||||
<options.addButton addItem={addItem} values={values} />
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={({ active }) => {
|
||||
// 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)}
|
||||
>
|
||||
<SortableContext items={values} strategy={verticalListSortingStrategy}>
|
||||
<Stack gap="xs">
|
||||
<>
|
||||
{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 (
|
||||
<MemoizedItem
|
||||
key={value}
|
||||
id={value}
|
||||
index={index}
|
||||
item={item}
|
||||
removeItem={removeItem}
|
||||
options={options}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{isLoading && (
|
||||
<Center h={256}>
|
||||
<Loader />
|
||||
</Center>
|
||||
)}
|
||||
{error && <Center h={256}>{JSON.stringify(error)}</Center>}
|
||||
</>
|
||||
</Stack>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
);
|
||||
};
|
||||
|
||||
interface ItemProps<TItem, TOptionValue extends UniqueIdentifier> {
|
||||
id: TOptionValue;
|
||||
item: TItem;
|
||||
index: number;
|
||||
removeItem: () => void;
|
||||
options: CommonWidgetInputProps<"sortableItemList">["options"];
|
||||
}
|
||||
|
||||
const Item = <TItem, TOptionValue extends UniqueIdentifier>({
|
||||
id,
|
||||
index,
|
||||
item,
|
||||
removeItem,
|
||||
options,
|
||||
}: ItemProps<TItem, TOptionValue>) => {
|
||||
const { attributes, isDragging, listeners, setNodeRef, setActivatorNodeRef, transform, transition } = useSortable({
|
||||
id,
|
||||
});
|
||||
|
||||
const Handle = (props: Partial<ActionIconProps>) => {
|
||||
return (
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
{...props}
|
||||
{...listeners}
|
||||
ref={setActivatorNodeRef}
|
||||
style={{ cursor: "grab" }}
|
||||
>
|
||||
<IconGripHorizontal />
|
||||
</ActionIcon>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
withBorder
|
||||
shadow="xs"
|
||||
padding="sm"
|
||||
radius="md"
|
||||
style={
|
||||
{
|
||||
transition: [transition].filter(Boolean).join(", "),
|
||||
"--translate-x": transform ? `${Math.round(transform.x)}px` : undefined,
|
||||
"--translate-y": transform ? `${Math.round(transform.y)}px` : undefined,
|
||||
"--scale-x": transform?.scaleX ? `${transform.scaleX}` : undefined,
|
||||
"--scale-y": transform?.scaleY ? `${transform.scaleY}` : undefined,
|
||||
"--index": index,
|
||||
transform:
|
||||
"translate3d(var(--translate-x, 0), var(--translate-y, 0), 0) scaleX(var(--scale-x, 1)) scaleY(var(--scale-y, 1))",
|
||||
transformOrigin: "0 0",
|
||||
...(isDragging
|
||||
? {
|
||||
opacity: "var(--dragging-opacity, 0.5)",
|
||||
zIndex: 0,
|
||||
}
|
||||
: {}),
|
||||
} as React.CSSProperties
|
||||
}
|
||||
ref={setNodeRef}
|
||||
>
|
||||
<options.itemComponent
|
||||
key={index}
|
||||
item={item}
|
||||
removeItem={removeItem}
|
||||
rootAttributes={attributes}
|
||||
handle={Handle}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const MemoizedItem = memo(Item);
|
||||
114
packages/widgets/src/bookmarks/app-select-modal.tsx
Normal file
114
packages/widgets/src/bookmarks/app-select-modal.tsx
Normal file
@@ -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<void>;
|
||||
confirmLabel?: string;
|
||||
}
|
||||
|
||||
interface AppSelectFormType {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const AppSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const { data: apps, isPending } = clientApi.app.selectable.useQuery();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const form = useForm<AppSelectFormType>();
|
||||
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 (
|
||||
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
|
||||
<Stack>
|
||||
<Select
|
||||
{...form.getInputProps("id")}
|
||||
label={t("app.action.select.label")}
|
||||
searchable
|
||||
clearable
|
||||
leftSection={<MemoizedLeftSection isPending={isPending} currentApp={currentApp} />}
|
||||
nothingFoundMessage={t("app.action.select.notFound")}
|
||||
renderOption={renderSelectOption}
|
||||
limit={5}
|
||||
data={
|
||||
apps
|
||||
?.filter((app) => !innerProps.presentAppIds.includes(app.id))
|
||||
.map((app) => ({
|
||||
label: app.name,
|
||||
value: app.id,
|
||||
iconUrl: app.iconUrl,
|
||||
})) ?? []
|
||||
}
|
||||
/>
|
||||
<Group justify="end">
|
||||
<Button variant="default" onClick={actions.closeModal}>
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={loading}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle: (t) => t("app.action.select.label"),
|
||||
});
|
||||
|
||||
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);
|
||||
3
packages/widgets/src/bookmarks/bookmark.module.css
Normal file
3
packages/widgets/src/bookmarks/bookmark.module.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.card:hover {
|
||||
background-color: var(--mantine-color-primaryColor-light-hover);
|
||||
}
|
||||
160
packages/widgets/src/bookmarks/component.tsx
Normal file
160
packages/widgets/src/bookmarks/component.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { Anchor, Box, Card, Divider, Flex, Group, Stack, Text, Title, UnstyledButton } from "@mantine/core";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import classes from "./bookmark.module.css";
|
||||
|
||||
export default function BookmarksWidget({ options, width, height }: WidgetComponentProps<"bookmarks">) {
|
||||
const [data] = clientApi.app.byIds.useSuspenseQuery(options.items, {
|
||||
select(data) {
|
||||
return data.sort((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Stack h="100%" gap="sm" p="sm">
|
||||
<Title order={4} px="0.25rem">
|
||||
{options.title}
|
||||
</Title>
|
||||
{options.layout === "grid" && <GridLayout data={data} width={width} height={height} />}
|
||||
{options.layout !== "grid" && <FlexLayout data={data} direction={options.layout} />}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface FlexLayoutProps {
|
||||
data: RouterOutputs["app"]["byIds"];
|
||||
direction: "row" | "column";
|
||||
}
|
||||
|
||||
const FlexLayout = ({ data, direction }: FlexLayoutProps) => {
|
||||
return (
|
||||
<Flex direction={direction} gap="0" h="100%" w="100%">
|
||||
{data.map((app, index) => (
|
||||
<div key={app.id} style={{ display: "flex", flex: "1", flexDirection: direction }}>
|
||||
<Divider
|
||||
m="3px"
|
||||
orientation={direction !== "column" ? "vertical" : "horizontal"}
|
||||
color={index === 0 ? "transparent" : undefined}
|
||||
/>
|
||||
<UnstyledButton
|
||||
component="a"
|
||||
href={app.href ?? undefined}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
key={app.id}
|
||||
h="100%"
|
||||
w="100%"
|
||||
>
|
||||
<Card
|
||||
radius="md"
|
||||
style={{ containerType: "size" }}
|
||||
className={classes.card}
|
||||
h="100%"
|
||||
w="100%"
|
||||
display="flex"
|
||||
p={0}
|
||||
>
|
||||
{direction === "row" ? <VerticalItem app={app} /> : <HorizontalItem app={app} />}
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
</div>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
interface GridLayoutProps {
|
||||
data: RouterOutputs["app"]["byIds"];
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const GridLayout = ({ data, width, height }: GridLayoutProps) => {
|
||||
// Calculates the perfect number of columns for the grid layout based on the width and height in pixels and the number of items
|
||||
const columns = Math.ceil(Math.sqrt(data.length * (width / height)));
|
||||
|
||||
return (
|
||||
<Box
|
||||
display="grid"
|
||||
h="100%"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${columns}, auto)`,
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{data.map((app) => (
|
||||
<UnstyledButton
|
||||
component="a"
|
||||
href={app.href ?? undefined}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
key={app.id}
|
||||
h="100%"
|
||||
>
|
||||
<Card withBorder style={{ containerType: "size" }} h="100%" className={classes.card} p="5cqmin">
|
||||
<VerticalItem app={app} />
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const VerticalItem = ({ app }: { app: RouterOutputs["app"]["byIds"][number] }) => {
|
||||
return (
|
||||
<Stack h="100%" gap="5cqmin">
|
||||
<Text fw={700} ta="center" size="20cqmin">
|
||||
{app.name}
|
||||
</Text>
|
||||
<img
|
||||
style={{
|
||||
maxHeight: "100%",
|
||||
maxWidth: "100%",
|
||||
overflow: "auto",
|
||||
flex: 1,
|
||||
objectFit: "contain",
|
||||
scale: 0.8,
|
||||
}}
|
||||
src={app.iconUrl}
|
||||
alt={app.name}
|
||||
/>
|
||||
<Anchor ta="center" component="span" size="12cqmin">
|
||||
{app.href ? new URL(app.href).hostname : undefined}
|
||||
</Anchor>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const HorizontalItem = ({ app }: { app: RouterOutputs["app"]["byIds"][number] }) => {
|
||||
return (
|
||||
<Group wrap="nowrap">
|
||||
<img
|
||||
style={{
|
||||
overflow: "auto",
|
||||
objectFit: "contain",
|
||||
scale: 0.8,
|
||||
minHeight: "100cqh",
|
||||
maxHeight: "100cqh",
|
||||
minWidth: "100cqh",
|
||||
maxWidth: "100cqh",
|
||||
}}
|
||||
src={app.iconUrl}
|
||||
alt={app.name}
|
||||
/>
|
||||
<Stack justify="space-between" gap={0}>
|
||||
<Text fw={700} size="45cqh" lineClamp={1}>
|
||||
{app.name}
|
||||
</Text>
|
||||
|
||||
<Anchor component="span" size="30cqh">
|
||||
{app.href ? new URL(app.href).hostname : undefined}
|
||||
</Anchor>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
67
packages/widgets/src/bookmarks/index.tsx
Normal file
67
packages/widgets/src/bookmarks/index.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { ActionIcon, Avatar, Button, Group, Stack, Text } from "@mantine/core";
|
||||
import { IconClock, IconX } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
import { AppSelectModal } from "./app-select-modal";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("bookmarks", {
|
||||
icon: IconClock,
|
||||
options: optionsBuilder.from((factory) => ({
|
||||
title: factory.text(),
|
||||
layout: factory.select({
|
||||
options: (["grid", "row", "column"] as const).map((value) => ({
|
||||
value,
|
||||
label: (t) => t(`widget.bookmarks.option.layout.option.${value}.label`),
|
||||
})),
|
||||
defaultValue: "column",
|
||||
}),
|
||||
items: factory.sortableItemList<RouterOutputs["app"]["all"][number], string>({
|
||||
ItemComponent: ({ item, handle: Handle, removeItem, rootAttributes }) => {
|
||||
return (
|
||||
<Group {...rootAttributes} tabIndex={0} justify="space-between" wrap="nowrap">
|
||||
<Group wrap="nowrap">
|
||||
<Handle />
|
||||
|
||||
<Group>
|
||||
<Avatar src={item.iconUrl} alt={item.name} />
|
||||
<Stack gap={0}>
|
||||
<Text>{item.name}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<ActionIcon variant="transparent" color="red" onClick={removeItem}>
|
||||
<IconX size={20} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
AddButton({ addItem, values }) {
|
||||
const { openModal } = useModalAction(AppSelectModal);
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Button onClick={() => openModal({ onSelect: addItem, presentAppIds: values })}>
|
||||
{t("widget.bookmarks.option.items.add")}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
uniqueIdentifier: (item) => item.id,
|
||||
useData: (initialIds) => {
|
||||
const { data, error, isLoading } = clientApi.app.byIds.useQuery(initialIds);
|
||||
|
||||
return {
|
||||
data,
|
||||
error,
|
||||
isLoading,
|
||||
};
|
||||
},
|
||||
}),
|
||||
})),
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
@@ -6,6 +6,7 @@ import { Center, Loader as UiLoader } from "@mantine/core";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
|
||||
import * as app from "./app";
|
||||
import * as bookmarks from "./bookmarks";
|
||||
import * as calendar from "./calendar";
|
||||
import * as clock from "./clock";
|
||||
import type { WidgetComponentProps } from "./definition";
|
||||
@@ -49,6 +50,7 @@ export const widgetImports = {
|
||||
"mediaRequests-requestList": mediaRequestsList,
|
||||
"mediaRequests-requestStats": mediaRequestsStats,
|
||||
rssFeed,
|
||||
bookmarks,
|
||||
indexerManager,
|
||||
healthMonitoring,
|
||||
} satisfies WidgetImportRecord;
|
||||
|
||||
@@ -106,7 +106,15 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Input key={key} kind={innerProps.kind} property={key} options={value as never} />;
|
||||
return (
|
||||
<Input
|
||||
key={key}
|
||||
kind={innerProps.kind}
|
||||
property={key}
|
||||
options={value as never}
|
||||
initialOptions={innerProps.value.options}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Group justify="space-between">
|
||||
<Button
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import type React from "react";
|
||||
import type { DraggableAttributes, UniqueIdentifier } from "@dnd-kit/core";
|
||||
import type { ActionIconProps } from "@mantine/core";
|
||||
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import type { ZodType } from "@homarr/validation";
|
||||
@@ -21,6 +25,19 @@ interface MultiSelectInput<TOptions extends SelectOption[]>
|
||||
searchable?: boolean;
|
||||
}
|
||||
|
||||
interface SortableItemListInput<TItem, TOptionValue extends UniqueIdentifier>
|
||||
extends Omit<CommonInput<TOptionValue[]>, "withDescription"> {
|
||||
AddButton: (props: { addItem: (item: TItem) => void; values: TOptionValue[] }) => React.ReactNode;
|
||||
ItemComponent: (props: {
|
||||
item: TItem;
|
||||
removeItem: () => void;
|
||||
rootAttributes: DraggableAttributes;
|
||||
handle: (props: Partial<Pick<ActionIconProps, "size" | "color" | "variant">>) => React.ReactNode;
|
||||
}) => React.ReactNode;
|
||||
uniqueIdentifier: (item: TItem) => TOptionValue;
|
||||
useData: (values: TOptionValue[]) => { data: TItem[] | undefined; isLoading: boolean; error: unknown };
|
||||
}
|
||||
|
||||
interface SelectInput<TOptions extends readonly SelectOption[]>
|
||||
extends CommonInput<inferSelectOptionValue<TOptions[number]>> {
|
||||
options: TOptions;
|
||||
@@ -109,10 +126,26 @@ const optionsFactory = {
|
||||
defaultValue: "",
|
||||
withDescription: false,
|
||||
}),
|
||||
sortableItemList: <const TItem, const TOptionValue extends UniqueIdentifier>(
|
||||
input: SortableItemListInput<TItem, TOptionValue>,
|
||||
) => ({
|
||||
type: "sortableItemList" as const,
|
||||
defaultValue: [] as TOptionValue[],
|
||||
itemComponent: input.ItemComponent,
|
||||
addButton: input.AddButton,
|
||||
uniqueIdentifier: input.uniqueIdentifier,
|
||||
useData: input.useData,
|
||||
withDescription: false,
|
||||
}),
|
||||
};
|
||||
|
||||
type WidgetOptionFactory = typeof optionsFactory;
|
||||
export type WidgetOptionDefinition = ReturnType<WidgetOptionFactory[keyof WidgetOptionFactory]>;
|
||||
|
||||
export type WidgetOptionDefinition =
|
||||
| ReturnType<WidgetOptionFactory[Exclude<keyof WidgetOptionFactory, "sortableItemList">]>
|
||||
// We allow any here as it's already type guarded with Record<string, unknown> and it still infers the correct type
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
| ReturnType<typeof optionsFactory.sortableItemList<any, any>>;
|
||||
export type WidgetOptionsRecord = Record<string, WidgetOptionDefinition>;
|
||||
export type WidgetOptionType = WidgetOptionDefinition["type"];
|
||||
export type WidgetOptionOfType<TType extends WidgetOptionType> = Extract<WidgetOptionDefinition, { type: TType }>;
|
||||
|
||||
53
pnpm-lock.yaml
generated
53
pnpm-lock.yaml
generated
@@ -1518,6 +1518,12 @@ importers:
|
||||
|
||||
packages/widgets:
|
||||
dependencies:
|
||||
'@dnd-kit/core':
|
||||
specifier: ^6.1.0
|
||||
version: 6.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@dnd-kit/sortable':
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0(@dnd-kit/core@6.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
|
||||
'@extractus/feed-extractor':
|
||||
specifier: ^7.1.3
|
||||
version: 7.1.3
|
||||
@@ -1899,6 +1905,28 @@ packages:
|
||||
'@dabh/diagnostics@2.0.3':
|
||||
resolution: {integrity: sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==}
|
||||
|
||||
'@dnd-kit/accessibility@3.1.0':
|
||||
resolution: {integrity: sha512-ea7IkhKvlJUv9iSHJOnxinBcoOI3ppGnnL+VDJ75O45Nss6HtZd8IdN8touXPDtASfeI2T2LImb8VOZcL47wjQ==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/core@6.1.0':
|
||||
resolution: {integrity: sha512-J3cQBClB4TVxwGo3KEjssGEXNJqGVWx17aRTZ1ob0FliR5IjYgTxl5YJbKTzA6IzrtelotH19v6y7uoIRUZPSg==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/sortable@8.0.0':
|
||||
resolution: {integrity: sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==}
|
||||
peerDependencies:
|
||||
'@dnd-kit/core': ^6.1.0
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/utilities@3.2.2':
|
||||
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@drizzle-team/brocli@0.10.2':
|
||||
resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
|
||||
|
||||
@@ -8389,6 +8417,31 @@ snapshots:
|
||||
enabled: 2.0.0
|
||||
kuler: 2.0.0
|
||||
|
||||
'@dnd-kit/accessibility@3.1.0(react@18.3.1)':
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
tslib: 2.7.0
|
||||
|
||||
'@dnd-kit/core@6.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@dnd-kit/accessibility': 3.1.0(react@18.3.1)
|
||||
'@dnd-kit/utilities': 3.2.2(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
tslib: 2.7.0
|
||||
|
||||
'@dnd-kit/sortable@8.0.0(@dnd-kit/core@6.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@dnd-kit/core': 6.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@dnd-kit/utilities': 3.2.2(react@18.3.1)
|
||||
react: 18.3.1
|
||||
tslib: 2.7.0
|
||||
|
||||
'@dnd-kit/utilities@3.2.2(react@18.3.1)':
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
tslib: 2.7.0
|
||||
|
||||
'@drizzle-team/brocli@0.10.2': {}
|
||||
|
||||
'@esbuild-kit/core-utils@3.3.2':
|
||||
|
||||
Reference in New Issue
Block a user