Files
homarr/packages/widgets/src/bookmarks/bookmarks-widget.tsx
T

365 lines
11 KiB
TypeScript

"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) => (
<BookmarkCard
key={bookmark.id}
bookmark={bookmark}
advanced={advanced}
orientation={cardDisplay.orientation}
showHostname={cardDisplay.showHostname}
showIcon={cardDisplay.showIcon}
showTitle={cardDisplay.showTitle}
openNewTab={options.openNewTab}
variant={options.variant}
withBorder={options.withBorder}
radius={board.itemRadius}
height={plan.itemHeight}
width={plan.horizontalScroll ? plan.itemWidth : undefined}
/>
));
if (appIds.length > 0 && isInitialWidgetQueryPending(appsQuery)) return <WidgetQueryLoadingState />;
return (
<Stack h="100%" mih={0} gap={height < 120 ? 6 : "sm"} p={height < 120 ? 6 : "sm"}>
{options.title.length > 0 ? (
<Text fz={11} fw={600} px={2} lh={1.2} lineClamp={1}>
{options.title}
</Text>
) : null}
{data.length === 0 ? (
<Center flex={1}>
<Stack align="center" gap={6}>
<ThemeIcon variant="light" size="lg" radius="xl">
<IconBookmark style={iconSizes.xl} />
</ThemeIcon>
<Text size="sm" c="dimmed" ta="center">
{t("empty")}
</Text>
</Stack>
</Center>
) : (
<ScrollArea scrollbars={plan.horizontalScroll ? "x" : "y"} style={{ flex: 1, minHeight: 0 }}>
<Box miw="100%" mih="100%" pb={2}>
{plan.horizontalScroll ? (
<Group gap={options.spacing} wrap="nowrap" mih="100%" align="stretch">
{cards}
</Group>
) : (
<SimpleGrid cols={plan.columns} spacing={options.spacing} verticalSpacing={options.spacing}>
{cards}
</SimpleGrid>
)}
</Box>
</ScrollArea>
)}
</Stack>
);
}
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 ? (
<Stack h="100%" align="center" justify="center" gap={6} flex={1}>
{showIcon ? <BookmarkAvatar bookmark={bookmark} iconUrl={iconUrl} size={iconOnly ? 34 : 38} /> : null}
{showTitle ? (
<Text size="xxs" fw={600} lh={1.2} ta="center" lineClamp={2}>
{bookmark.name}
</Text>
) : null}
{showHostname ? (
<Text fz={9} c="dimmed" ta="center" truncate w="100%">
{hostname}
</Text>
) : null}
</Stack>
) : (
<Group
h="100%"
gap={advanced ? "sm" : "xs"}
wrap="nowrap"
justify={iconOnly ? "center" : "flex-start"}
align="center"
flex={1}
>
{showIcon ? (
<BookmarkAvatar bookmark={bookmark} iconUrl={iconUrl} size={advanced ? 42 : iconOnly ? 32 : 30} />
) : null}
{!iconOnly ? (
<Stack gap={advanced ? 3 : 0} miw={0} flex={1}>
{showTitle ? (
<Text size={advanced ? "xs" : "xxs"} fw={600} lh={1.2} truncate>
{bookmark.name}
</Text>
) : null}
{showHostname ? (
<Text fz={advanced ? 10 : 9} c="dimmed" truncate>
{hostname}
</Text>
) : null}
{advanced && bookmark.description ? (
<Text fz={10} c="dimmed" lineClamp={2}>
{bookmark.description}
</Text>
) : null}
</Stack>
) : null}
{advanced ? (
<ThemeIcon
variant={active ? "light" : "transparent"}
color={active ? "primaryColor" : "gray"}
size="sm"
radius="xl"
style={{
opacity: active ? 1 : 0.45,
transform: active && !reduceMotion ? "translate(1px, -1px)" : undefined,
transition: reduceMotion ? undefined : "opacity 120ms ease, transform 120ms ease",
}}
>
<IconArrowUpRight style={iconSizes.sm} />
</ThemeIcon>
) : null}
</Group>
);
return (
<Tooltip
label={
<Stack gap={0}>
<Text size="xs" fw={600}>
{bookmark.name}
</Text>
{hostname ? (
<Text size="xs" opacity={0.8}>
{hostname}
</Text>
) : null}
</Stack>
}
openDelay={450}
disabled={advanced}
withArrow
>
<Card
component={href ? "a" : "div"}
href={href}
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
aria-label={bookmark.name}
radius={radius}
withBorder={withBorder || variant === "outline"}
p={iconOnly ? 6 : advanced ? "md" : "xs"}
h={height}
w={width}
miw={width}
onMouseEnter={() => 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}
</Card>
</Tooltip>
);
};
const BookmarkAvatar = ({ bookmark, iconUrl, size }: { bookmark: BookmarkItem; iconUrl?: string; size: number }) => (
<Avatar
src={iconUrl}
name={bookmark.name}
color="gray"
radius="sm"
size={size}
imageProps={{ referrerPolicy: "no-referrer" }}
styles={{ image: { objectFit: "contain" } }}
>
<IconLink style={zoomCompensatedSize(Math.max(14, size / 2))} />
</Avatar>
);
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;
}
};