"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 { iconSizes, zoomCompensatedSize } from "@homarr/ui"; import { getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url"; import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state"; import { WidgetQueryLoadingState } from "../common/query-state-indicator"; 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 appsQuery = clientApi.app.byIds.useQuery(appIds, { select(selectedApps) { return selectedApps; }, }); const apps = getUsableWidgetQueryData(appsQuery) ?? []; 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, dedupeKey: appsById.has(bookmark.id) ? `app:${bookmark.id}` : undefined, 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) => ( )); if (appIds.length > 0 && isInitialWidgetQueryPending(appsQuery)) return ; 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; } };