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;