"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"; export { default } from "./bookmarks-widget"; 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 function LegacyBookmarksWidget({ 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 ? ( ) : ( );