diff --git a/apps/docs/docs/widgets/bookmarks/index.mdx b/apps/docs/docs/widgets/bookmarks/index.mdx index b043ccc9c..d712c4a9d 100644 --- a/apps/docs/docs/widgets/bookmarks/index.mdx +++ b/apps/docs/docs/widgets/bookmarks/index.mdx @@ -1,6 +1,6 @@ --- title: "Bookmarks" -description: "Displays multiple app links" +description: "Keeps useful links together" hide_title: true --- @@ -13,11 +13,12 @@ import { bookmarksWidget } from "."; -The bookmarks widget allows you to display multiple links in a single widget. -Select one or more existing Apps directly in the widget settings; these bookmarks keep a reference to the App, so changes to its name, icon, or URL are reflected automatically. -For links that do not need an App, add their URLs under **Custom links** instead. -Its normal view adapts each configured layout to the available width and height: lower-priority hostnames are hidden before names or icons, and larger collections scroll instead of crushing cards. -Advanced view shows every bookmark as a roomy card with its full title, hostname, icon, and complete description regardless of the compact layout and visibility choices. +The bookmarks widget keeps direct URLs and Homarr Apps in one ordered list. +Paste a URL and press Enter, paste several comma-separated or one-per-line URLs, or search for an existing App. +URLs do not need an App record; Homarr uses the site's favicon and falls back to a link icon when it cannot load one. +Bookmarks selected from Apps continue to use the App name, URL, icon, and description. + +**Adaptive** layout is the practical default: it changes between icons, compact rows, and tiles as the widget changes shape. Every compact item exposes its destination on hover, while advanced view expands items into descriptive cards. ### Screenshots diff --git a/apps/docs/docs/widgets/bookmarks/index.ts b/apps/docs/docs/widgets/bookmarks/index.ts index eaeec258e..b1a766ade 100644 --- a/apps/docs/docs/widgets/bookmarks/index.ts +++ b/apps/docs/docs/widgets/bookmarks/index.ts @@ -4,7 +4,7 @@ import { IconBookmark } from "@tabler/icons-react"; export const bookmarksWidget: WidgetDefinition = { icon: IconBookmark, name: "Bookmarks", - description: "Displays multiple app links", + description: "Keeps useful links together", path: "../../widgets/bookmarks", configuration: { items: [ @@ -16,9 +16,21 @@ export const bookmarksWidget: WidgetDefinition = { }, { name: "Layout", - description: "Layout in which the bookmarks are displayed.", - values: { type: "select", options: ["Vertical", "Horizontal", "Grid", "Grid horizontal"] }, - defaultValue: "Vertical", + description: "How bookmarks use the available widget space.", + values: { type: "select", options: ["Adaptive", "Vertical", "Horizontal", "Grid", "Compact grid", "Icons"] }, + defaultValue: "Adaptive", + }, + { + name: "Appearance", + description: "Card surface style.", + values: { type: "select", options: ["Soft", "Filled", "Outline", "Plain"] }, + defaultValue: "Soft", + }, + { + name: "Card spacing", + description: "Space between bookmark cards.", + values: { type: "select", options: ["Extra small", "Small", "Medium", "Large", "Extra large"] }, + defaultValue: "Extra small", }, { name: "Hide title", @@ -46,16 +58,10 @@ export const bookmarksWidget: WidgetDefinition = { }, { name: "Bookmarks", - description: "List of bookmarks to display in the widget.", - values: "Select one or more Apps directly, then order them as you like.", + description: "URLs and existing Apps shown by the widget.", + values: "Paste one or many URLs, or find an existing App. Drag to reorder.", defaultValue: "No bookmarks selected", }, - { - name: "Custom links", - description: "URLs to display without first creating an App.", - values: "List of URLs.", - defaultValue: "No custom links", - }, ], }, }; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index ad0be64bf..dc27c91c8 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -2192,8 +2192,51 @@ "layout": { "label": "Layout", "option": { + "adaptive": { + "label": "Adaptive" + }, "gridHorizontal": { - "label": "Grid horizontal" + "label": "Compact grid" + }, + "icons": { + "label": "Icons" + } + } + }, + "variant": { + "label": "Appearance", + "option": { + "soft": { + "label": "Soft" + }, + "filled": { + "label": "Filled" + }, + "outline": { + "label": "Outline" + }, + "plain": { + "label": "Plain" + } + } + }, + "spacing": { + "label": "Card spacing", + "option": { + "xs": { + "label": "Extra small" + }, + "sm": { + "label": "Small" + }, + "md": { + "label": "Medium" + }, + "lg": { + "label": "Large" + }, + "xl": { + "label": "Extra large" } } }, @@ -2210,17 +2253,21 @@ "label": "Show border" }, "customUrls": { - "label": "Custom links", - "description": "Add URLs directly without creating an App first" + "label": "Custom links" }, "items": { "label": "Bookmarks", + "remove": "Remove bookmark", "apps": { - "label": "Apps", - "placeholder": "Select one or more apps" + "label": "Add bookmarks", + "description": "Paste URLs or find existing Apps. Drag the bookmarks below to reorder them.", + "placeholder": "Paste URLs or search Apps", + "addUrl": "Add this URL", + "hint": "Press Enter to add a URL. Paste comma-separated or one-per-line URLs to add several." } } - } + }, + "empty": "Add a URL or App in the widget settings" }, "archiveTeamWarrior": { "name": "ArchiveTeam Warrior", diff --git a/packages/ui/src/theme.ts b/packages/ui/src/theme.ts index 2afa0f689..decaabbe2 100644 --- a/packages/ui/src/theme.ts +++ b/packages/ui/src/theme.ts @@ -25,6 +25,7 @@ export const theme = createTheme({ }, fontSizes: { + xxs: rem(11), xs: rem(12), sm: rem(14), md: rem(16), diff --git a/packages/widgets/src/_inputs/common.tsx b/packages/widgets/src/_inputs/common.tsx index 8d56de939..446d9608f 100644 --- a/packages/widgets/src/_inputs/common.tsx +++ b/packages/widgets/src/_inputs/common.tsx @@ -16,7 +16,7 @@ export interface CommonWidgetInputProps { boardId?: string; } -type UseWidgetInputTranslationReturnType = (key: "label" | "description" | "placeholder") => string; +type UseWidgetInputTranslationReturnType = (key: "label" | "description" | "placeholder" | "remove") => string; /** * Short description why as and unknown convertions are used below: diff --git a/packages/widgets/src/_inputs/widget-sortable-item-list-input.tsx b/packages/widgets/src/_inputs/widget-sortable-item-list-input.tsx index 7789a5fb8..afe0a63ba 100644 --- a/packages/widgets/src/_inputs/widget-sortable-item-list-input.tsx +++ b/packages/widgets/src/_inputs/widget-sortable-item-list-input.tsx @@ -96,6 +96,28 @@ export const WidgetSortedItemListInput = [...currentValues, options.uniqueIdentifier(item) as TOptionValue]); }; + const migrateItems = useCallback( + (items: TItem[], optionsPatch: Record) => { + const migratedEntries = items.map((item) => [options.uniqueIdentifier(item) as TOptionValue, item] as const); + setTempMap((previous) => { + const next = new Map(previous); + for (const [value, item] of migratedEntries) next.set(value, item); + return next; + }); + form.setFieldValue("options", (currentOptions) => { + const currentItems = Array.isArray(currentOptions[property]) + ? (currentOptions[property] as TOptionValue[]) + : options.defaultValue; + const nextItems = [...currentItems]; + for (const [value] of migratedEntries) { + if (!nextItems.includes(value)) nextItems.push(value); + } + return { ...currentOptions, ...optionsPatch, [property]: nextItems }; + }); + }, + [form, options, property], + ); + const removeItem = useCallback( (value: TOptionValue) => { updateItems((currentValues) => currentValues.filter((candidate) => candidate !== value)); @@ -112,7 +134,13 @@ export const WidgetSortedItemListInput = - + removeItem(value)} + removeLabel={t("remove")} options={options} /> ); @@ -177,6 +206,7 @@ interface ItemProps { item: TItem; index: number; removeItem: () => void; + removeLabel: string; options: CommonWidgetInputProps<"sortableItemList">["options"]; } @@ -185,6 +215,7 @@ const Item = ({ index, item, removeItem, + removeLabel, options, }: ItemProps) => { const { attributes, isDragging, listeners, setNodeRef, setActivatorNodeRef, transform, transition } = useSortable({ @@ -220,6 +251,7 @@ const Item = ({ key={index} item={item} removeItem={removeItem} + removeLabel={removeLabel} rootAttributes={attributes} handle={ ["AddButton"] = ({ +const createOptionValue = "$create-url"; + +export const BookmarkAddButton: SortableItemListInput["AddButton"] = ({ addItem, - removeItem, + migrateItems, values, + initialOptions, }) => { const t = useI18n("widget.bookmarks.option.items.apps"); const tCommon = useI18n("common"); const tSelect = useI18n("app.action.select"); const { data: apps = [], isPending, error } = clientApi.app.selectable.useQuery(); - const appsById = new Map(apps.map((app) => [app.id, app])); + const [search, setSearch] = useState(""); + const combobox = useCombobox({ + onDropdownClose: () => combobox.resetSelectedOption(), + onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"), + }); + const selectedValues = useMemo(() => new Set(values), [values]); + const migrationStartedRef = useRef(false); + const legacyUrls = useMemo(() => { + if (!Array.isArray(initialOptions.customUrls)) return []; + return initialOptions.customUrls.filter((value): value is string => typeof value === "string"); + }, [initialOptions.customUrls]); - const handleChange = (nextValues: string[]) => { - const nextIds = new Set(nextValues); - for (const value of values) { - if (!nextIds.has(value)) removeItem(value); - } + useEffect(() => { + if (migrationStartedRef.current || legacyUrls.length === 0) return; - const currentIds = new Set(values); - for (const value of nextValues) { - if (currentIds.has(value)) continue; - const app = appsById.get(value); - if (app) addItem(app); + migrationStartedRef.current = true; + const legacyItems = legacyUrls.flatMap((url) => createDirectBookmark(url) ?? []); + migrateItems(legacyItems, { customUrls: [] }); + }, [legacyUrls, migrateItems]); + + const normalizedSearch = search.trim().toLowerCase(); + const filteredApps = apps.filter((app) => { + if (selectedValues.has(app.id)) return false; + if (normalizedSearch.length === 0) return true; + + return app.name.toLowerCase().includes(normalizedSearch) || app.href?.toLowerCase().includes(normalizedSearch); + }); + const pendingUrl = createDirectBookmark(search); + + const addUrls = (input: string) => { + const nextValues = new Set(values); + for (const value of splitBookmarkUrls(input)) { + const item = createDirectBookmark(value); + if (!item || nextValues.has(item.id)) continue; + addItem(item); + nextValues.add(item.id); } + setSearch(""); + combobox.closeDropdown(); }; + const handleOptionSubmit = (value: string) => { + if (value === createOptionValue) { + addUrls(search); + return; + } + + const app = apps.find((candidate) => candidate.id === value); + if (app) addItem(app); + setSearch(""); + }; + + const options = filteredApps.map((app) => ( + + + + + + + + {app.name} + + + {app.href} + + + + + + )); + return ( - : undefined} - nothingFoundMessage={tSelect("notFound")} - renderOption={renderAppOption} - data={apps.map((app) => ({ - value: app.id, - label: app.name, - iconUrl: app.iconUrl, - }))} - /> + + + combobox.openDropdown()} + leftSection={ + + + + } + rightSection={isPending ? : undefined} + styles={{ input: { minHeight: 44 } }} + > + + combobox.openDropdown()} + onBlur={() => combobox.closeDropdown()} + onChange={(event) => { + setSearch(event.currentTarget.value); + combobox.openDropdown(); + combobox.updateSelectedOptionIndex(); + }} + onPaste={(event) => { + const pastedValue = event.clipboardData.getData("text"); + if (splitBookmarkUrls(pastedValue).length < 2) return; + event.preventDefault(); + addUrls(pastedValue); + }} + onKeyDown={(event) => { + if (event.key !== "Enter") return; + if (!pendingUrl) return; + event.preventDefault(); + addUrls(search); + }} + /> + + + + + + + + {pendingUrl && !selectedValues.has(pendingUrl.id) ? ( + + + + + + + + {t("addUrl")} + + + {pendingUrl.href} + + + + + + ) : null} + {options} + {!pendingUrl && options.length === 0 ? {tSelect("notFound")} : null} + + + + + {t("hint")} + + + + ); }; - -const iconProps = { - stroke: 1.5, - color: "currentColor", - opacity: 0.6, - size: 18, -}; - -const renderAppOption: MultiSelectProps["renderOption"] = ({ option, checked }) => ( - - {"iconUrl" in option && typeof option.iconUrl === "string" ? ( - - ) : null} - {option.label} - {checked && } - -); diff --git a/packages/widgets/src/bookmarks/bookmark-item.ts b/packages/widgets/src/bookmarks/bookmark-item.ts new file mode 100644 index 000000000..37b3a1e24 --- /dev/null +++ b/packages/widgets/src/bookmarks/bookmark-item.ts @@ -0,0 +1,66 @@ +import { getSafeApplicationUrl } from "../common/application-url"; + +export const directBookmarkPrefix = "url:"; + +export interface BookmarkItem { + id: string; + name: string; + description: string | null; + iconUrl?: string; + href: string | null; +} + +export const normalizeBookmarkUrl = (value: string): string | undefined => { + const trimmedValue = value.trim(); + if (trimmedValue.length === 0) return undefined; + + const directUrl = getSafeApplicationUrl(trimmedValue); + if (directUrl) return directUrl; + + if (/^(?:localhost(?::\d+)?|(?:[\w-]+\.)+[\w-]+(?::\d+)?)(?:[/?#].*)?$/i.test(trimmedValue)) { + return getSafeApplicationUrl(`https://${trimmedValue}`); + } + + return undefined; +}; + +export const getDirectBookmarkValue = (url: string) => `${directBookmarkPrefix}${url}`; + +export const getDirectBookmarkUrl = (value: string): string | undefined => { + if (!value.startsWith(directBookmarkPrefix)) return undefined; + return normalizeBookmarkUrl(value.slice(directBookmarkPrefix.length)); +}; + +export const getBookmarkFaviconUrl = (href: string | null): string | undefined => { + const safeUrl = getSafeApplicationUrl(href); + if (!safeUrl) return undefined; + + const url = new URL(safeUrl); + return `${url.origin}/favicon.ico`; +}; + +export const createDirectBookmark = (url: string): BookmarkItem | undefined => { + const href = normalizeBookmarkUrl(url); + if (!href) return undefined; + + const parsedUrl = new URL(href); + return { + id: getDirectBookmarkValue(href), + name: parsedUrl.hostname, + description: null, + href, + }; +}; + +export const splitBookmarkUrls = (value: string) => + value.split(/\r?\n+/).flatMap((line) => { + const trimmedLine = line.trim(); + if (trimmedLine.length === 0) return []; + + const commaSeparatedValues = trimmedLine.split(",").map((part) => part.trim()); + if (commaSeparatedValues.length > 1 && commaSeparatedValues.every((part) => normalizeBookmarkUrl(part))) { + return commaSeparatedValues; + } + + return [trimmedLine]; + }); diff --git a/packages/widgets/src/bookmarks/bookmark.module.css b/packages/widgets/src/bookmarks/bookmark.module.css deleted file mode 100644 index cc5662ac5..000000000 --- a/packages/widgets/src/bookmarks/bookmark.module.css +++ /dev/null @@ -1,45 +0,0 @@ -.card { - background-color: transparent; - border-color: rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.38)); - transition: - background-color 120ms ease, - border-color 120ms ease; -} - -.card:hover, -.card:focus-within { - background-color: transparent; - border-color: rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.62)); -} - -.bookmarkButton:focus-visible { - outline: 2px solid var(--mantine-primary-color-filled); - outline-offset: 2px; - border-radius: var(--mantine-radius-sm); -} - -.card:hover > div > div.bookmarkIcon { - background-color: var(--mantine-color-iconColor-filled-hover); -} - -.bookmarkButton:focus-visible .bookmarkIcon { - background-color: var(--mantine-color-iconColor-filled-hover); -} - -@media (prefers-reduced-motion: reduce) { - .card { - transition: none; - } -} - -.compactScrollViewport > div { - display: flex !important; - width: 100%; - min-height: 100%; -} - -.compactScrollContent { - flex: 1; - min-width: 100%; - min-height: 100%; -} diff --git a/packages/widgets/src/bookmarks/bookmarks-widget.tsx b/packages/widgets/src/bookmarks/bookmarks-widget.tsx new file mode 100644 index 000000000..3d8188c24 --- /dev/null +++ b/packages/widgets/src/bookmarks/bookmarks-widget.tsx @@ -0,0 +1,368 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + Avatar, + Box, + Card, + Center, + Group, + ScrollArea, + SimpleGrid, + Stack, + Text, + ThemeIcon, + Tooltip, +} from "@mantine/core"; +import { useReducedMotion } from "@mantine/hooks"; +import { IconArrowUpRight, IconBookmark, IconLink } from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { useRequiredBoard } from "@homarr/boards/context"; +import { useRegisterSpotlightContextResults } from "@homarr/spotlight"; +import { useI18n } from "@homarr/translation/client"; + +import { getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url"; +import { getUsableWidgetQueryData } from "../common/query-state"; +import type { WidgetComponentProps } from "../definition"; +import { createDirectBookmark, getBookmarkFaviconUrl, getDirectBookmarkUrl } from "./bookmark-item"; +import type { BookmarkItem } from "./bookmark-item"; +import { getBookmarkCardDisplay, getBookmarkDisplayPlan } from "./layout"; +import type { BookmarkOrientation } from "./layout"; + +type BookmarkVariant = WidgetComponentProps<"bookmarks">["options"]["variant"]; + +export default function BookmarksWidget({ + options, + itemId, + width, + height, + displayMode, +}: WidgetComponentProps<"bookmarks">) { + const t = useI18n("widget.bookmarks"); + const board = useRequiredBoard(); + const advanced = displayMode === "advanced"; + const appIds = useMemo(() => options.items.filter((value) => !getDirectBookmarkUrl(value)), [options.items]); + const apps = + getUsableWidgetQueryData( + clientApi.app.byIds.useQuery(appIds, { + select(selectedApps) { + return selectedApps; + }, + }), + ) ?? []; + const appsById = new Map(apps.map((app) => [app.id, app])); + const configuredItems = options.items.flatMap((value) => { + const directUrl = getDirectBookmarkUrl(value); + if (directUrl) return createDirectBookmark(directUrl) ?? []; + return appsById.get(value) ?? []; + }); + const configuredHrefs = new Set(configuredItems.map((item) => item.href)); + const legacyItems = options.customUrls.flatMap((url) => { + const item = createDirectBookmark(url); + if (!item || configuredHrefs.has(item.href)) return []; + return [item]; + }); + const data = [...configuredItems, ...legacyItems]; + + useRegisterSpotlightContextResults( + `bookmark-${itemId}`, + data.flatMap((bookmark) => { + const href = getSafeAppHref(bookmark.href); + if (!href) return []; + + return [ + { + id: bookmark.id, + name: bookmark.name, + icon: bookmark.iconUrl ?? IconLink, + interaction() { + return { + type: "link" as const, + href, + newTab: false, + }; + }, + }, + ]; + }), + [data], + ); + + const plan = useMemo( + () => + getBookmarkDisplayPlan({ + advanced, + height: options.title.length > 0 ? height - 36 : height, + itemCount: data.length, + layout: options.layout, + width, + }), + [advanced, data.length, height, options.layout, options.title.length, width], + ); + + const cardDisplay = getBookmarkCardDisplay({ + advanced, + hideHostname: options.hideHostname, + hideIcon: options.hideIcon, + hideTitle: options.hideTitle, + plan, + }); + + const cards = data.map((bookmark) => ( + + )); + + return ( + + {options.title.length > 0 ? ( + + {options.title} + + ) : null} + + {data.length === 0 ? ( + + + + + + + {t("empty")} + + + + ) : ( + + + {plan.horizontalScroll ? ( + + {cards} + + ) : ( + + {cards} + + )} + + + )} + + ); +} + +interface BookmarkCardProps { + advanced: boolean; + bookmark: BookmarkItem; + height: number; + openNewTab: boolean; + orientation: BookmarkOrientation; + radius: string; + showHostname: boolean; + showIcon: boolean; + showTitle: boolean; + variant: BookmarkVariant; + width?: number; + withBorder: boolean; +} + +const BookmarkCard = ({ + advanced, + bookmark, + height, + openNewTab, + orientation, + radius, + showHostname, + showIcon, + showTitle, + variant, + width, + withBorder, +}: BookmarkCardProps) => { + const [active, setActive] = useState(false); + const reduceMotion = useReducedMotion(); + const href = getSafeAppHref(bookmark.href); + const hostname = bookmark.href ? getBookmarkHostname(bookmark.href) : undefined; + const iconUrl = bookmark.iconUrl ?? getBookmarkFaviconUrl(bookmark.href); + const iconOnly = orientation === "icon" || (!showTitle && !showHostname); + const background = getBookmarkBackground(variant, active); + + const content = + orientation === "vertical" && !advanced ? ( + + {showIcon ? : null} + {showTitle ? ( + + {bookmark.name} + + ) : null} + {showHostname ? ( + + {hostname} + + ) : null} + + ) : ( + + {showIcon ? ( + + ) : null} + {!iconOnly ? ( + + {showTitle ? ( + + {bookmark.name} + + ) : null} + {showHostname ? ( + + {hostname} + + ) : null} + {advanced && bookmark.description ? ( + + {bookmark.description} + + ) : null} + + ) : null} + {advanced ? ( + + + + ) : null} + + ); + + return ( + + + {bookmark.name} + + {hostname ? ( + + {hostname} + + ) : null} + + } + openDelay={450} + disabled={advanced} + withArrow + > + setActive(true)} + onMouseLeave={() => setActive(false)} + onFocus={() => setActive(true)} + onBlur={() => setActive(false)} + styles={{ + root: { + background, + borderColor: active + ? "rgb(from var(--mantine-primary-color-filled) r g b / calc(var(--opacity, 1) * 0.62))" + : "rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.38))", + boxShadow: active ? "var(--mantine-shadow-sm)" : undefined, + color: "var(--mantine-color-text)", + cursor: href ? "pointer" : "default", + display: "flex", + outline: active ? "2px solid var(--mantine-primary-color-light)" : undefined, + outlineOffset: -2, + overflow: "hidden", + textDecoration: "none", + transform: active && !reduceMotion ? "translateY(-1px)" : undefined, + transition: reduceMotion + ? undefined + : "background-color 120ms ease, border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease", + }, + }} + > + {content} + + + ); +}; + +const BookmarkAvatar = ({ bookmark, iconUrl, size }: { bookmark: BookmarkItem; iconUrl?: string; size: number }) => ( + + + +); + +const getBookmarkBackground = (variant: BookmarkVariant, active: boolean): string => { + if (variant === "plain" || variant === "outline") return "transparent"; + if (variant === "filled") { + const opacity = active ? 0.64 : 0.48; + return `rgb(from var(--mantine-color-default-hover) r g b / calc(var(--opacity, 1) * ${opacity}))`; + } + + const opacity = active ? 0.14 : 0.08; + return `rgb(from var(--mantine-primary-color-filled) r g b / calc(var(--opacity, 1) * ${opacity}))`; +}; + +const getBookmarkHostname = (href: string): string | undefined => { + try { + return new URL(href).hostname || undefined; + } catch { + return undefined; + } +}; diff --git a/packages/widgets/src/bookmarks/component.tsx b/packages/widgets/src/bookmarks/component.tsx index 9a9317657..3d3909d84 100644 --- a/packages/widgets/src/bookmarks/component.tsx +++ b/packages/widgets/src/bookmarks/component.tsx @@ -1,532 +1,3 @@ "use client"; -import type { CSSProperties } from "react"; -import { useMemo } from "react"; -import { Anchor, Card, Flex, Group, ScrollArea, SimpleGrid, Stack, Text, Title, UnstyledButton } from "@mantine/core"; -import { useElementSize } from "@mantine/hooks"; -import { IconLink } from "@tabler/icons-react"; - -import { clientApi } from "@homarr/api/client"; -import { useRequiredBoard } from "@homarr/boards/context"; -import { useRegisterSpotlightContextResults } from "@homarr/spotlight"; -import { MaskedOrNormalImage } from "@homarr/ui"; - -import type { WidgetComponentProps } from "../definition"; -import { getSafeApplicationUrl, getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url"; -import { getUsableWidgetQueryData } from "../common/query-state"; -import classes from "./bookmark.module.css"; - -type BookmarkLayout = WidgetComponentProps<"bookmarks">["options"]["layout"]; - -const COMPACT_GRID_GAP = 4; - -export interface BookmarkDisplayItem { - id: string; - name: string; - description: string | null; - iconUrl?: string; - href: string | null; -} - -export interface CompactBookmarkLayout { - columns: number; - hideHostname: boolean; - hideTitle: boolean; - minimumItemSize: number; -} - -export function getCompactBookmarkLayout( - width: number, - height: number, - itemCount: number, - layout: BookmarkLayout, -): CompactBookmarkLayout { - const count = Math.max(1, itemCount); - const isGrid = layout === "grid" || layout === "gridHorizontal"; - const targetWidth = layout === "gridHorizontal" ? 180 : 104; - let columns = 1; - if (isGrid) { - columns = Math.max(1, Math.min(count, Math.floor((width + COMPACT_GRID_GAP) / (targetWidth + COMPACT_GRID_GAP)))); - } else if (layout === "row") { - columns = count; - } - const rows = Math.max(1, Math.ceil(count / columns)); - const cellWidth = (width - (columns - 1) * COMPACT_GRID_GAP) / columns; - const cellHeight = height / rows; - const usesHorizontalItems = layout === "column" || layout === "gridHorizontal"; - - return { - columns, - hideHostname: cellWidth < 120 || cellHeight < (usesHorizontalItems ? 52 : 96), - hideTitle: cellWidth < 64 || cellHeight < 40, - minimumItemSize: usesHorizontalItems ? 48 : 96, - }; -} - -export default function BookmarksWidget({ - options, - itemId, - width, - height, - displayMode, -}: WidgetComponentProps<"bookmarks">) { - const board = useRequiredBoard(); - const { ref: compactContentRef, width: compactContentWidth } = useElementSize(); - const apps = - getUsableWidgetQueryData( - clientApi.app.byIds.useQuery(options.items, { - select(selectedApps) { - return selectedApps.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id)); - }, - }), - ) ?? []; - const customLinks = useMemo( - () => options.customUrls.flatMap((url) => createCustomBookmark(url) ?? []), - [options.customUrls], - ); - const data: BookmarkDisplayItem[] = [...apps, ...customLinks]; - - useRegisterSpotlightContextResults( - `bookmark-${itemId}`, - data.flatMap((app) => { - const href = getSafeAppHref(app.href); - return href - ? [ - { - id: app.id, - name: app.name, - icon: app.iconUrl ?? IconLink, - interaction() { - return { - type: "link", - href, - newTab: false, - }; - }, - }, - ] - : []; - }), - [data], - ); - - const compactLayout = useMemo( - () => getCompactBookmarkLayout(compactContentWidth || width, height, data.length, options.layout), - [compactContentWidth, data.length, height, options.layout, width], - ); - const compactHideTitle = options.hideTitle || (compactLayout.hideTitle && !options.hideIcon); - const compactHideHostname = - options.hideHostname || (compactLayout.hideHostname && !(options.hideTitle && options.hideIcon)); - - return ( - - {options.title.length > 0 && ( - - {options.title} - - )} - {displayMode === "advanced" && ( - - )} - {displayMode !== "advanced" && ( - - - {options.layout === "grid" || options.layout === "gridHorizontal" ? ( - - ) : ( - - )} - - - )} - - ); -} - -interface FlexLayoutProps { - data: BookmarkDisplayItem[]; - direction: "row" | "column"; - hideTitle: boolean; - hideIcon: boolean; - hideHostname: boolean; - openNewTab: boolean; - withBorder: boolean; - hasIconColor: boolean; - minimumItemSize: number; -} - -const FlexLayout = ({ - data, - direction, - hideTitle, - hideIcon, - hideHostname, - openNewTab, - withBorder, - hasIconColor, - minimumItemSize, -}: FlexLayoutProps) => { - const board = useRequiredBoard(); - return ( - - {data.map((app) => { - const href = getSafeAppHref(app.href); - return ( - - - - {direction === "row" ? ( - - ) : ( - - )} - - - - ); - })} - - ); -}; - -interface AdvancedBookmarksLayoutProps { - data: BookmarkDisplayItem[]; - width: number; - openNewTab: boolean; - withBorder: boolean; - hasIconColor: boolean; -} - -export const getAdvancedBookmarkColumns = (width: number, itemCount: number): number => - Math.max(1, Math.min(itemCount || 1, Math.floor(width / 280))); - -export const getBookmarkHostname = (href: string | null): string | undefined => { - const safeHref = getSafeApplicationUrl(href); - return safeHref ? new URL(safeHref).hostname : undefined; -}; - -export const createCustomBookmark = (url: string): BookmarkDisplayItem | null => { - const normalizedUrl = url.trim(); - const href = getSafeAppHref(normalizedUrl); - if (!href) return null; - - return { - id: `custom-link:${normalizedUrl}`, - name: getBookmarkHostname(href) ?? normalizedUrl, - description: null, - href, - }; -}; - -const AdvancedBookmarksLayout = ({ - data, - width, - openNewTab, - withBorder, - hasIconColor, -}: AdvancedBookmarksLayoutProps) => { - const board = useRequiredBoard(); - - return ( - - - {data.map((app) => { - const href = getSafeAppHref(app.href); - return ( - - - - - - - {app.name} - - - {getBookmarkHostname(app.href)} - - {app.description && ( - - {app.description} - - )} - - - - - ); - })} - - - ); -}; - -interface GridLayoutProps { - data: BookmarkDisplayItem[]; - hideTitle: boolean; - hideIcon: boolean; - hideHostname: boolean; - openNewTab: boolean; - withBorder: boolean; - itemDirection: "horizontal" | "vertical"; - hasIconColor: boolean; - columns: number; - minimumItemHeight: number; -} - -const GridLayout = ({ - data, - hideTitle, - hideIcon, - hideHostname, - openNewTab, - withBorder, - itemDirection, - hasIconColor, - columns, - minimumItemHeight, -}: GridLayoutProps) => { - const board = useRequiredBoard(); - - return ( - - {data.map((app) => { - const href = getSafeAppHref(app.href); - return ( - - - {itemDirection === "horizontal" ? ( - - ) : ( - - )} - - - ); - })} - - ); -}; - -const VerticalItem = ({ - app, - hideTitle, - hideIcon, - hideHostname, - hasIconColor, -}: { - app: BookmarkDisplayItem; - hideTitle: boolean; - hideIcon: boolean; - hideHostname: boolean; - hasIconColor: boolean; -}) => { - return ( - - {!hideTitle && ( - - {app.name} - - )} - {!hideIcon && ( - - )} - {!hideHostname && ( - - {getBookmarkHostname(app.href)} - - )} - - ); -}; - -const HorizontalItem = ({ - app, - hideTitle, - hideIcon, - hideHostname, - hasIconColor, -}: { - app: BookmarkDisplayItem; - hideTitle: boolean; - hideIcon: boolean; - hideHostname: boolean; - hasIconColor: boolean; -}) => { - return ( - - {!hideIcon && ( - - )} - {!(hideTitle && hideHostname) && ( - <> - - {!hideTitle && ( - - {app.name} - - )} - - {!hideHostname && ( - - {getBookmarkHostname(app.href)} - - )} - - > - )} - - ); -}; - -interface BookmarkIconProps { - app: BookmarkDisplayItem; - hasColor: boolean; - className?: string; - style?: CSSProperties; -} - -const BookmarkIcon = ({ app, hasColor, className, style }: BookmarkIconProps) => - app.iconUrl ? ( - - ) : ( - - ); +export { default } from "./bookmarks-widget"; diff --git a/packages/widgets/src/bookmarks/index.tsx b/packages/widgets/src/bookmarks/index.tsx index 49f4d959e..82d171e9d 100644 --- a/packages/widgets/src/bookmarks/index.tsx +++ b/packages/widgets/src/bookmarks/index.tsx @@ -1,14 +1,13 @@ import { ActionIcon, Avatar, Group, Stack, Text } from "@mantine/core"; -import { IconBookmark, IconX } from "@tabler/icons-react"; -import { z } from "zod/v4"; +import { IconBookmark, IconLink, IconX } from "@tabler/icons-react"; -import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; -import { getSafeAppHref } from "../common/application-url"; import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; import { BookmarkAddButton } from "./add-button"; +import type { BookmarkSelectionItem } from "./add-button"; +import { createDirectBookmark, getBookmarkFaviconUrl, getDirectBookmarkUrl } from "./bookmark-item"; export const { definition, componentLoader } = createWidgetDefinition("bookmarks", { icon: IconBookmark, @@ -18,42 +17,63 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks return optionsBuilder.from((factory) => ({ title: factory.text(), layout: factory.select({ - options: (["grid", "gridHorizontal", "row", "column"] as const).map((value) => ({ + options: (["adaptive", "column", "row", "grid", "gridHorizontal", "icons"] as const).map((value) => ({ value, label: (t) => { + if (value === "adaptive") return t("widget.bookmarks.option.layout.option.adaptive.label"); if (value === "grid") return t("widget.common.layout.option.grid"); if (value === "row") return t("widget.common.layout.option.horizontal"); if (value === "column") return t("widget.common.layout.option.vertical"); + if (value === "icons") return t("widget.bookmarks.option.layout.option.icons.label"); return t("widget.bookmarks.option.layout.option.gridHorizontal.label"); }, })), - defaultValue: "column", + defaultValue: "adaptive", + }), + variant: factory.select({ + options: (["soft", "filled", "outline", "plain"] as const).map((value) => ({ + value, + label: (t) => t(`widget.bookmarks.option.variant.option.${value}.label`), + })), + defaultValue: "soft", + }), + spacing: factory.select({ + options: (["xs", "sm", "md", "lg", "xl"] as const).map((value) => ({ + value, + label: (t) => t(`widget.bookmarks.option.spacing.option.${value}.label`), + })), + defaultValue: "xs", }), hideTitle: factory.switch({ defaultValue: false }), hideIcon: factory.switch({ defaultValue: false }), hideHostname: factory.switch({ defaultValue: false }), openNewTab: factory.switch({ defaultValue: true }), withBorder: factory.switch({ defaultValue: false }), - customUrls: factory.multiText({ - validate: z.string().refine((value) => getSafeAppHref(value) !== undefined), - withDescription: true, - }), - items: factory.sortableItemList({ - ItemComponent: ({ item, handle, removeItem, rootAttributes }) => { + customUrls: factory.internal({ defaultValue: [] as string[] }), + items: factory.sortableItemList({ + ItemComponent: ({ item, handle, removeItem, removeLabel, rootAttributes }) => { + const iconUrl = item.iconUrl ?? getBookmarkFaviconUrl(item.href); return ( - + {handle} - - - - {item.name} + + + + + + + {item.name} + + + {item.href} + - + @@ -62,7 +82,14 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks AddButton: BookmarkAddButton, uniqueIdentifier: (item) => item.id, useData: (initialIds) => { - const { data, error, isLoading } = clientApi.app.byIds.useQuery(initialIds); + const appIds = initialIds.filter((value) => !getDirectBookmarkUrl(value)); + const { data: apps, error, isLoading } = clientApi.app.byIds.useQuery(appIds); + const appsById = new Map(apps?.map((app) => [app.id, app])); + const data = initialIds.flatMap((value) => { + const directUrl = getDirectBookmarkUrl(value); + if (directUrl) return createDirectBookmark(directUrl) ?? []; + return appsById.get(value) ?? []; + }); return { data, diff --git a/packages/widgets/src/bookmarks/layout.spec.ts b/packages/widgets/src/bookmarks/layout.spec.ts index 1b13809db..8c459af23 100644 --- a/packages/widgets/src/bookmarks/layout.spec.ts +++ b/packages/widgets/src/bookmarks/layout.spec.ts @@ -1,54 +1,69 @@ import { describe, expect, test } from "vitest"; -import { - createCustomBookmark, - getAdvancedBookmarkColumns, - getBookmarkHostname, - getCompactBookmarkLayout, -} from "./component"; +import { createDirectBookmark, normalizeBookmarkUrl, splitBookmarkUrls } from "./bookmark-item"; +import { getBookmarkCardDisplay, getBookmarkDisplayPlan } from "./layout"; describe("bookmark display helpers", () => { - test("uses bounded advanced columns", () => { - expect(getAdvancedBookmarkColumns(900, 8)).toBe(3); - expect(getAdvancedBookmarkColumns(120, 8)).toBe(1); - expect(getAdvancedBookmarkColumns(900, 0)).toBe(1); + test("uses explicit width breakpoints for advanced columns", () => { + expect( + getBookmarkDisplayPlan({ advanced: true, width: 900, height: 500, itemCount: 8, layout: "adaptive" }), + ).toMatchObject({ + columns: 3, + orientation: "horizontal", + }); + expect( + getBookmarkDisplayPlan({ advanced: true, width: 120, height: 500, itemCount: 8, layout: "adaptive" }), + ).toMatchObject({ + columns: 1, + orientation: "horizontal", + }); }); - test("uses container geometry to keep compact cards legible", () => { - expect(getCompactBookmarkLayout(420, 220, 4, "gridHorizontal")).toMatchObject({ + test("selects an adaptive plan from width and height breakpoints", () => { + expect( + getBookmarkDisplayPlan({ advanced: false, width: 560, height: 260, itemCount: 4, layout: "adaptive" }), + ).toMatchObject({ columns: 2, - hideHostname: false, - hideTitle: false, - minimumItemSize: 48, + itemHeight: 128, + orientation: "horizontal", + showHostname: true, + showTitle: true, }); - - expect(getCompactBookmarkLayout(180, 80, 4, "row")).toMatchObject({ - columns: 4, - hideHostname: true, - hideTitle: true, - minimumItemSize: 96, + expect( + getBookmarkDisplayPlan({ advanced: false, width: 180, height: 80, itemCount: 4, layout: "adaptive" }), + ).toMatchObject({ + columns: 1, + orientation: "icon", + showHostname: false, + showTitle: false, }); }); - test("does not crash on malformed bookmark URLs", () => { - expect(getBookmarkHostname("https://homarr.dev/docs")).toBe("homarr.dev"); - expect(getBookmarkHostname("not a URL")).toBeUndefined(); - expect(getBookmarkHostname(null)).toBeUndefined(); + test("keeps compact cards visible when icons are hidden", () => { + const plan = getBookmarkDisplayPlan({ advanced: false, width: 180, height: 80, itemCount: 4, layout: "icons" }); + expect( + getBookmarkCardDisplay({ advanced: false, hideHostname: false, hideIcon: true, hideTitle: false, plan }), + ).toEqual({ + orientation: "horizontal", + showHostname: false, + showIcon: false, + showTitle: true, + }); }); test("creates standalone bookmarks without app records", () => { - expect(createCustomBookmark("https://homarr.dev/docs")).toEqual({ - id: "custom-link:https://homarr.dev/docs", + expect(createDirectBookmark("https://homarr.dev/docs")).toEqual({ + id: "url:https://homarr.dev/docs", name: "homarr.dev", description: null, href: "https://homarr.dev/docs", }); - expect(createCustomBookmark(" https://homarr.dev/docs ")).toEqual({ - id: "custom-link:https://homarr.dev/docs", - name: "homarr.dev", - description: null, - href: "https://homarr.dev/docs", - }); - expect(createCustomBookmark("javascript:alert(1)")).toBeNull(); + expect(normalizeBookmarkUrl("homarr.dev/docs")).toBe("https://homarr.dev/docs"); + expect(normalizeBookmarkUrl("javascript:alert(1)")).toBeUndefined(); + }); + + test("splits URL lists without corrupting commas inside a URL", () => { + expect(splitBookmarkUrls("https://one.example, two.example")).toEqual(["https://one.example", "two.example"]); + expect(splitBookmarkUrls("https://maps.example/@52.5,13.4,15z")).toEqual(["https://maps.example/@52.5,13.4,15z"]); }); }); diff --git a/packages/widgets/src/bookmarks/layout.ts b/packages/widgets/src/bookmarks/layout.ts new file mode 100644 index 000000000..ada81edef --- /dev/null +++ b/packages/widgets/src/bookmarks/layout.ts @@ -0,0 +1,265 @@ +import type { WidgetComponentProps } from "../definition"; + +export type BookmarkLayout = WidgetComponentProps<"bookmarks">["options"]["layout"]; +export type BookmarkOrientation = "horizontal" | "vertical" | "icon"; + +export interface BookmarkDisplayPlan { + columns: number; + horizontalScroll: boolean; + itemHeight: number; + itemWidth: number; + orientation: BookmarkOrientation; + showHostname: boolean; + showTitle: boolean; +} + +export interface BookmarkCardDisplay { + orientation: BookmarkOrientation; + showHostname: boolean; + showIcon: boolean; + showTitle: boolean; +} + +export const getBookmarkCardDisplay = ({ + advanced, + hideHostname, + hideIcon, + hideTitle, + plan, +}: { + advanced: boolean; + hideHostname: boolean; + hideIcon: boolean; + hideTitle: boolean; + plan: BookmarkDisplayPlan; +}): BookmarkCardDisplay => { + let orientation = plan.orientation; + let showTitle = advanced || (!hideTitle && plan.showTitle); + if (!advanced && hideIcon && !hideTitle && !showTitle) { + orientation = "horizontal"; + showTitle = true; + } + const showHostname = advanced || (!hideHostname && plan.showHostname); + const showIcon = advanced || !hideIcon || (!showTitle && !showHostname); + + return { orientation, showHostname, showIcon, showTitle }; +}; + +interface BookmarkWidthBreakpoint { + adaptiveColumns: number; + adaptiveOrientation: BookmarkOrientation; + advancedColumns: number; + compactColumns: number; + gridColumns: number; + iconColumns: number; + minWidth: number; + rowItemWidth: number; + showHostname: boolean; + showTitle: boolean; +} + +const widthBreakpoints = [ + { + minWidth: 900, + adaptiveColumns: 4, + advancedColumns: 3, + compactColumns: 4, + gridColumns: 5, + iconColumns: 10, + rowItemWidth: 180, + adaptiveOrientation: "vertical", + showHostname: true, + showTitle: true, + }, + { + minWidth: 720, + adaptiveColumns: 3, + advancedColumns: 2, + compactColumns: 3, + gridColumns: 4, + iconColumns: 8, + rowItemWidth: 172, + adaptiveOrientation: "horizontal", + showHostname: true, + showTitle: true, + }, + { + minWidth: 480, + adaptiveColumns: 2, + advancedColumns: 2, + compactColumns: 2, + gridColumns: 3, + iconColumns: 6, + rowItemWidth: 160, + adaptiveOrientation: "horizontal", + showHostname: true, + showTitle: true, + }, + { + minWidth: 300, + adaptiveColumns: 2, + advancedColumns: 1, + compactColumns: 1, + gridColumns: 2, + iconColumns: 4, + rowItemWidth: 144, + adaptiveOrientation: "horizontal", + showHostname: false, + showTitle: true, + }, + { + minWidth: 0, + adaptiveColumns: 1, + advancedColumns: 1, + compactColumns: 1, + gridColumns: 1, + iconColumns: 2, + rowItemWidth: 112, + adaptiveOrientation: "icon", + showHostname: false, + showTitle: false, + }, +] as const satisfies readonly BookmarkWidthBreakpoint[]; + +interface BookmarkHeightBreakpoint { + columnItemHeight: number; + itemHeight: number; + minHeight: number; + orientation: BookmarkOrientation; + rowItemHeight: number; + showHostname: boolean; + showTitle: boolean; +} + +const heightBreakpoints = [ + { + minHeight: 300, + columnItemHeight: 72, + itemHeight: 128, + rowItemHeight: 128, + orientation: "vertical", + showHostname: true, + showTitle: true, + }, + { + minHeight: 180, + columnItemHeight: 64, + itemHeight: 128, + rowItemHeight: 112, + orientation: "horizontal", + showHostname: true, + showTitle: true, + }, + { + minHeight: 110, + columnItemHeight: 56, + itemHeight: 80, + rowItemHeight: 80, + orientation: "horizontal", + showHostname: false, + showTitle: true, + }, + { + minHeight: 0, + columnItemHeight: 48, + itemHeight: 56, + rowItemHeight: 56, + orientation: "icon", + showHostname: false, + showTitle: false, + }, +] as const satisfies readonly BookmarkHeightBreakpoint[]; + +export const getBookmarkDisplayPlan = ({ + advanced, + height, + itemCount, + layout, + width, +}: { + advanced: boolean; + height: number; + itemCount: number; + layout: BookmarkLayout; + width: number; +}): BookmarkDisplayPlan => { + const count = Math.max(1, itemCount); + const normalizedWidth = Math.max(0, width); + const normalizedHeight = Math.max(0, height); + const widthSettings = widthBreakpoints.find(({ minWidth }) => normalizedWidth >= minWidth); + const heightSettings = heightBreakpoints.find(({ minHeight }) => normalizedHeight >= minHeight); + if (!widthSettings || !heightSettings) throw new Error("Bookmark breakpoints must cover all widget sizes"); + + if (advanced) { + return { + columns: Math.min(count, widthSettings.advancedColumns), + horizontalScroll: false, + itemHeight: 104, + itemWidth: 260, + orientation: "horizontal", + showHostname: true, + showTitle: true, + }; + } + + if (layout === "row") { + let orientation: BookmarkOrientation = "vertical"; + if (!heightSettings.showTitle) orientation = "icon"; + return { + columns: count, + horizontalScroll: true, + itemHeight: heightSettings.rowItemHeight, + itemWidth: widthSettings.rowItemWidth, + orientation, + showHostname: widthSettings.showHostname && heightSettings.showHostname, + showTitle: widthSettings.showTitle && heightSettings.showTitle, + }; + } + + if (layout === "column") { + return { + columns: 1, + horizontalScroll: false, + itemHeight: heightSettings.columnItemHeight, + itemWidth: width, + orientation: "horizontal", + showHostname: widthSettings.showHostname && heightSettings.showHostname, + showTitle: widthSettings.showTitle && heightSettings.showTitle, + }; + } + + if (layout === "icons") { + return { + columns: Math.min(count, widthSettings.iconColumns), + horizontalScroll: false, + itemHeight: 56, + itemWidth: 56, + orientation: "icon", + showHostname: false, + showTitle: false, + }; + } + + let columns: number = widthSettings.compactColumns; + let orientation: BookmarkOrientation = "horizontal"; + if (layout === "grid") { + columns = widthSettings.gridColumns; + orientation = "vertical"; + } + if (layout === "adaptive") { + columns = widthSettings.adaptiveColumns; + orientation = widthSettings.adaptiveOrientation; + if (heightSettings.orientation === "icon") orientation = "icon"; + if (heightSettings.orientation === "horizontal" && orientation === "vertical") orientation = "horizontal"; + } + + return { + columns: Math.min(count, columns), + horizontalScroll: false, + itemHeight: heightSettings.itemHeight, + itemWidth: widthSettings.rowItemWidth, + orientation, + showHostname: orientation !== "icon" && widthSettings.showHostname && heightSettings.showHostname, + showTitle: orientation !== "icon" && widthSettings.showTitle && heightSettings.showTitle, + }; +}; diff --git a/packages/widgets/src/bookmarks/prefetch.ts b/packages/widgets/src/bookmarks/prefetch.ts index 616de0718..75f9373a4 100644 --- a/packages/widgets/src/bookmarks/prefetch.ts +++ b/packages/widgets/src/bookmarks/prefetch.ts @@ -4,11 +4,12 @@ import { apps } from "@homarr/db/schema"; import type { Prefetch } from "../definition"; import { createTrpcQueryKey } from "../trpc-query-key"; +import { getDirectBookmarkUrl } from "./bookmark-item"; const logger = createLogger({ module: "bookmarksWidgetPrefetch" }); const prefetchAllAsync: Prefetch<"bookmarks"> = async (queryClient, items) => { - const appIds = items.flatMap((item) => item.options.items); + const appIds = items.flatMap((item) => item.options.items.filter((value) => !getDirectBookmarkUrl(value))); const distinctAppIds = [...new Set(appIds)]; const dbApps = await db.query.apps.findMany({ @@ -16,13 +17,14 @@ const prefetchAllAsync: Prefetch<"bookmarks"> = async (queryClient, items) => { }); for (const item of items) { - if (item.options.items.length === 0) { + const itemAppIds = item.options.items.filter((value) => !getDirectBookmarkUrl(value)); + if (itemAppIds.length === 0) { continue; } queryClient.setQueryData( - createTrpcQueryKey("app.byIds", item.options.items), - dbApps.filter((app) => item.options.items.includes(app.id)), + createTrpcQueryKey("app.byIds", itemAppIds), + dbApps.filter((app) => itemAppIds.includes(app.id)), ); } diff --git a/packages/widgets/src/options.ts b/packages/widgets/src/options.ts index 74e20a83c..2d39a554d 100644 --- a/packages/widgets/src/options.ts +++ b/packages/widgets/src/options.ts @@ -34,12 +34,15 @@ export interface SortableItemListInput { AddButton: (props: { addItem: (item: TItem) => void; + migrateItems: (items: TItem[], optionsPatch: Record) => void; removeItem: (value: TOptionValue) => void; values: TOptionValue[]; + initialOptions: Record; }) => React.ReactNode; ItemComponent: (props: { item: TItem; removeItem: () => void; + removeLabel: string; rootAttributes: DraggableAttributes; handle: React.ReactNode; }) => React.ReactNode; diff --git a/packages/widgets/src/weather/index.ts b/packages/widgets/src/weather/index.ts index 3252ff59f..53c890b4c 100644 --- a/packages/widgets/src/weather/index.ts +++ b/packages/widgets/src/weather/index.ts @@ -26,8 +26,8 @@ export const { definition, componentLoader } = createWidgetDefinition("weather", isFormatFahrenheit: factory.switch(), disableTemperatureDecimals: factory.switch(), animateIcons: factory.switch(), - showHumidity: factory.switch({ defaultValue: false, withDescription: true }), - showCurrentWindSpeed: factory.switch({ withDescription: true }), + showHumidity: factory.switch({ defaultValue: false }), + showCurrentWindSpeed: factory.switch(), useImperialSpeed: factory.switch(), location: factory.location({ defaultValue: {