"use client"; import { useMemo } from "react"; import { Anchor, Box, Card, Flex, Group, ScrollArea, SimpleGrid, Stack, Text, Title, UnstyledButton, } from "@mantine/core"; import combineClasses from "clsx"; import type { RouterOutputs } from "@homarr/api"; 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"]; 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; const columns = isGrid ? Math.max(1, Math.min(count, Math.floor(width / targetWidth))) : layout === "row" ? count : 1; const rows = Math.max(1, Math.ceil(count / columns)); const cellWidth = width / 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 data = getUsableWidgetQueryData( clientApi.app.byIds.useQuery(options.items, { select(apps) { return apps.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id)); }, }), ) ?? []; useRegisterSpotlightContextResults( `bookmark-${itemId}`, data.flatMap((app) => { const href = getSafeAppHref(app.href); return href ? [ { id: app.id, name: app.name, icon: app.iconUrl, interaction() { return { type: "link", href, newTab: false, }; }, }, ] : []; }), [data], ); const compactLayout = useMemo( () => getCompactBookmarkLayout(width, height, data.length, options.layout), [width, height, data.length, options.layout], ); 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: RouterOutputs["app"]["byIds"]; 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: RouterOutputs["app"]["byIds"]; 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; }; 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: RouterOutputs["app"]["byIds"]; 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: RouterOutputs["app"]["byIds"][number]; 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: RouterOutputs["app"]["byIds"][number]; hideTitle: boolean; hideIcon: boolean; hideHostname: boolean; hasIconColor: boolean; }) => { return ( {!hideIcon && ( )} {!(hideTitle && hideHostname) && ( <> {!hideTitle && ( {app.name} )} {!hideHostname && ( {getBookmarkHostname(app.href)} )} )} ); };