From 09ecb3f0b044be98603db8230707bde864df3aed Mon Sep 17 00:00:00 2001 From: ajnart Date: Sat, 22 Aug 2026 15:46:04 +0200 Subject: [PATCH] feat: rework bookmarks widget --- apps/docs/docs/widgets/bookmarks/index.mdx | 13 +- apps/docs/docs/widgets/bookmarks/index.ts | 24 +- packages/translation/src/lang/en.json | 39 +- .../widget-sortable-item-list-input.tsx | 2 +- packages/widgets/src/bookmarks/add-button.tsx | 229 +++++++-- .../widgets/src/bookmarks/bookmark-item.ts | 59 +++ .../src/bookmarks/bookmarks-widget.tsx | 465 ++++++++++++++++++ packages/widgets/src/bookmarks/component.tsx | 6 +- packages/widgets/src/bookmarks/index.tsx | 56 ++- packages/widgets/src/bookmarks/prefetch.ts | 10 +- packages/widgets/src/options.ts | 1 + 11 files changed, 801 insertions(+), 103 deletions(-) create mode 100644 packages/widgets/src/bookmarks/bookmark-item.ts create mode 100644 packages/widgets/src/bookmarks/bookmarks-widget.tsx 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..544f7ed99 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,15 @@ 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: "Hide title", @@ -46,16 +52,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..7d61e18d8 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -2192,8 +2192,31 @@ "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" } } }, @@ -2209,18 +2232,18 @@ "withBorder": { "label": "Show border" }, - "customUrls": { - "label": "Custom links", - "description": "Add URLs directly without creating an App first" - }, "items": { "label": "Bookmarks", "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/widgets/src/_inputs/widget-sortable-item-list-input.tsx b/packages/widgets/src/_inputs/widget-sortable-item-list-input.tsx index 7789a5fb8..719598f52 100644 --- a/packages/widgets/src/_inputs/widget-sortable-item-list-input.tsx +++ b/packages/widgets/src/_inputs/widget-sortable-item-list-input.tsx @@ -112,7 +112,7 @@ export const WidgetSortedItemListInput = - + ["AddButton"] = ({ +const createOptionValue = "$create-url"; + +export const BookmarkAddButton: SortableItemListInput["AddButton"] = ({ addItem, - removeItem, 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 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(() => { + for (const url of legacyUrls) { + const item = createDirectBookmark(url); + if (item && !selectedValues.has(item.id)) addItem(item); } + }, [addItem, legacyUrls, selectedValues]); - const currentIds = new Set(values); - for (const value of nextValues) { - if (currentIds.has(value)) continue; - const app = appsById.get(value); - if (app) addItem(app); + const normalizedSearch = search.trim().toLocaleLowerCase(); + const filteredApps = apps.filter((app) => { + if (selectedValues.has(app.id)) return false; + if (normalizedSearch.length === 0) return true; + + return ( + app.name.toLocaleLowerCase().includes(normalizedSearch) || + app.href?.toLocaleLowerCase().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" && event.key !== ",") return; + if (!pendingUrl) return; + event.preventDefault(); + addUrls(search); + }} + /> + + + + + + + + {pendingUrl && !selectedValues.has(getDirectBookmarkValue(pendingUrl.href ?? "")) ? ( + + + + + + + + {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..6ca2115be --- /dev/null +++ b/packages/widgets/src/bookmarks/bookmark-item.ts @@ -0,0 +1,59 @@ +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(/[,\n]+/) + .map((part) => part.trim()) + .filter(Boolean); diff --git a/packages/widgets/src/bookmarks/bookmarks-widget.tsx b/packages/widgets/src/bookmarks/bookmarks-widget.tsx new file mode 100644 index 000000000..c7d222b89 --- /dev/null +++ b/packages/widgets/src/bookmarks/bookmarks-widget.tsx @@ -0,0 +1,465 @@ +"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"; + +type BookmarkLayout = WidgetComponentProps<"bookmarks">["options"]["layout"]; +type BookmarkVariant = WidgetComponentProps<"bookmarks">["options"]["variant"]; +type BookmarkOrientation = "horizontal" | "vertical" | "icon"; + +interface BookmarkDisplayPlan { + columns: number; + horizontalScroll: boolean; + itemHeight: number; + itemWidth: number; + orientation: BookmarkOrientation; + showHostname: boolean; + showTitle: boolean; +} + +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 availableWidth = Math.max(1, width - 16); + const availableHeight = Math.max(1, height - 16); + + if (advanced) { + const columns = Math.max(1, Math.min(count, Math.floor(availableWidth / 260))); + return { + columns, + horizontalScroll: false, + itemHeight: 104, + itemWidth: 260, + orientation: "horizontal", + showHostname: true, + showTitle: true, + }; + } + + if (layout === "row") { + const itemWidth = Math.max(96, Math.min(180, availableWidth / Math.min(count, 4))); + return { + columns: count, + horizontalScroll: true, + itemHeight: Math.max(48, availableHeight), + itemWidth, + orientation: availableHeight < 64 ? "icon" : "vertical", + showHostname: availableHeight >= 112, + showTitle: availableHeight >= 72, + }; + } + + if (layout === "column") { + const itemHeight = Math.max(46, Math.min(72, availableHeight / count)); + return { + columns: 1, + horizontalScroll: false, + itemHeight, + itemWidth: availableWidth, + orientation: "horizontal", + showHostname: itemHeight >= 56 && availableWidth >= 150, + showTitle: availableWidth >= 92, + }; + } + + if (layout === "icons") { + const columns = Math.max(1, Math.min(count, Math.floor(availableWidth / 64))); + return { + columns, + horizontalScroll: false, + itemHeight: 56, + itemWidth: 56, + orientation: "icon", + showHostname: false, + showTitle: false, + }; + } + + const minimumWidth = layout === "gridHorizontal" ? 168 : layout === "grid" ? 104 : 64; + let columns = Math.max(1, Math.min(count, Math.floor(availableWidth / minimumWidth))); + if (layout === "adaptive") { + const targetAspectRatio = 1.7; + const idealColumns = Math.max( + 1, + Math.round(Math.sqrt((availableWidth * count) / (availableHeight * targetAspectRatio))), + ); + columns = Math.min(columns, idealColumns); + } + const rows = Math.max(1, Math.ceil(count / columns)); + const cellWidth = availableWidth / columns; + const cellHeight = availableHeight / rows; + let orientation: BookmarkOrientation = layout === "grid" ? "vertical" : "horizontal"; + + if (layout === "adaptive") { + if (cellHeight < 54 || cellWidth < 72) orientation = "icon"; + else if (cellWidth / cellHeight > 1.35 || cellHeight < 92) orientation = "horizontal"; + else orientation = "vertical"; + } + + return { + columns, + horizontalScroll: false, + itemHeight: Math.max(52, Math.min(128, cellHeight)), + itemWidth: layout === "adaptive" ? 144 : minimumWidth, + orientation, + showHostname: orientation !== "icon" && cellWidth >= 138 && cellHeight >= 64, + showTitle: orientation !== "icon" && cellWidth >= 88 && cellHeight >= 48, + }; +}; + +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 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 ? "var(--mantine-primary-color-filled)" : undefined, + 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 (active) return "var(--mantine-primary-color-light-hover)"; + if (variant === "plain" || variant === "outline") return "transparent"; + if (variant === "filled") return "var(--mantine-color-default-hover)"; + return "var(--mantine-primary-color-light)"; +}; + +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..e70c84448 100644 --- a/packages/widgets/src/bookmarks/component.tsx +++ b/packages/widgets/src/bookmarks/component.tsx @@ -16,6 +16,8 @@ import { getSafeApplicationUrl, getSafeAppHref, SAFE_NEW_TAB_REL } from "../comm import { getUsableWidgetQueryData } from "../common/query-state"; import classes from "./bookmark.module.css"; +export { default } from "./bookmarks-widget"; + type BookmarkLayout = WidgetComponentProps<"bookmarks">["options"]["layout"]; const COMPACT_GRID_GAP = 4; @@ -63,7 +65,7 @@ export function getCompactBookmarkLayout( }; } -export default function BookmarksWidget({ +export function LegacyBookmarksWidget({ options, itemId, width, @@ -158,7 +160,7 @@ export default function BookmarksWidget({ ) : ( ({ 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", }), 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({ + customUrls: factory.internal({ defaultValue: [] as string[] }), + items: factory.sortableItemList({ ItemComponent: ({ item, handle, removeItem, rootAttributes }) => { + const iconUrl = item.iconUrl ?? getBookmarkFaviconUrl(item.href); return ( - + {handle} - - - - {item.name} + + + + + + + {item.name} + + + {item.href} + - + @@ -62,7 +75,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/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..42572d598 100644 --- a/packages/widgets/src/options.ts +++ b/packages/widgets/src/options.ts @@ -36,6 +36,7 @@ export interface SortableItemListInput void; removeItem: (value: TOptionValue) => void; values: TOptionValue[]; + initialOptions: Record; }) => React.ReactNode; ItemComponent: (props: { item: TItem;