feat: add bookmark widget (#964)

* feat: add bookmark widget

* fix: item component type issue, widget-ordered-object-list-input item component issue

* feat: add button in items list

* wip

* wip: bookmark options dnd

* wip: improve widget sortable item list

* feat: add sortable item list input to widget edit modal

* feat: implement bookmark widget

* chore: address pull request feedback

* fix: format issues

* fix: lockfile not up to date

* fix: import configuration missing and apps not imported

* fix: bookmark items not sorted

* feat: add flex layouts to bookmark widget

* fix: deepsource issue

* fix: add missing layout bookmarks old-import options mapping

---------

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
Manuel
2024-11-02 18:44:36 +01:00
committed by GitHub
parent f8bdd9c5c7
commit 49c0ebea6d
21 changed files with 889 additions and 32 deletions

View File

@@ -7,6 +7,7 @@ export interface CommonWidgetInputProps<TKey extends WidgetOptionType> {
kind: WidgetKind;
property: string;
options: Omit<WidgetOptionOfType<TKey>, "defaultValue" | "type">;
initialOptions: Record<string, unknown>;
}
type UseWidgetInputTranslationReturnType = (key: "label" | "description") => string;

View File

@@ -6,6 +6,7 @@ import { WidgetMultiSelectInput } from "./widget-multiselect-input";
import { WidgetNumberInput } from "./widget-number-input";
import { WidgetSelectInput } from "./widget-select-input";
import { WidgetSliderInput } from "./widget-slider-input";
import { WidgetSortedItemListInput } from "./widget-sortable-item-list-input";
import { WidgetSwitchInput } from "./widget-switch-input";
import { WidgetTextInput } from "./widget-text-input";
@@ -19,6 +20,7 @@ const mapping = {
slider: WidgetSliderInput,
switch: WidgetSwitchInput,
app: WidgetAppInput,
sortableItemList: WidgetSortedItemListInput,
} satisfies Record<WidgetOptionType, unknown>;
export const getInputForType = <TType extends WidgetOptionType>(type: TType) => {

View File

@@ -0,0 +1,233 @@
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { UniqueIdentifier } from "@dnd-kit/core";
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import type { ActionIconProps } from "@mantine/core";
import { ActionIcon, Card, Center, Fieldset, Loader, Stack } from "@mantine/core";
import { IconGripHorizontal } from "@tabler/icons-react";
import { useWidgetInputTranslation } from "./common";
import type { CommonWidgetInputProps } from "./common";
import { useFormContext } from "./form";
export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIdentifier>({
property,
options,
initialOptions,
kind,
}: CommonWidgetInputProps<"sortableItemList">) => {
const t = useWidgetInputTranslation(kind, property);
const form = useFormContext();
const initialValues = useMemo(() => initialOptions[property] as TOptionValue[], [initialOptions, property]);
const values = form.values.options[property] as TOptionValue[];
const { data, isLoading, error } = options.useData(initialValues);
const dataMap = useMemo(
() => new Map(data?.map((item) => [options.uniqueIdentifier(item), item as TItem])),
[data, options],
);
const [tempMap, setTempMap] = useState<Map<TOptionValue, TItem>>(new Map());
const [activeId, setActiveId] = useState<TOptionValue | null>(null);
const sensors = useSensors(
useSensor(MouseSensor),
useSensor(TouchSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const isFirstAnnouncement = useRef(true);
const getIndex = (id: TOptionValue) => values.indexOf(id);
const activeIndex = activeId ? getIndex(activeId) : -1;
useEffect(() => {
if (!activeId) {
isFirstAnnouncement.current = true;
}
}, [activeId]);
const getItem = useCallback(
(id: TOptionValue) => {
if (!tempMap.has(id)) {
return dataMap.get(id);
}
return tempMap.get(id);
},
[tempMap, dataMap],
);
const updateItems = (callback: (prev: TOptionValue[]) => TOptionValue[]) => {
form.setFieldValue(`options.${property}`, callback);
};
const addItem = (item: TItem) => {
setTempMap((prev) => {
prev.set(options.uniqueIdentifier(item) as TOptionValue, item);
return prev;
});
updateItems((values) => [...values, options.uniqueIdentifier(item) as TOptionValue]);
};
return (
<Fieldset legend={t("label")}>
<Stack>
<options.addButton addItem={addItem} values={values} />
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={({ active }) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!active) {
return;
}
setActiveId(active.id as TOptionValue);
}}
onDragEnd={({ over }) => {
setActiveId(null);
if (over) {
const overIndex = getIndex(over.id as TOptionValue);
if (activeIndex !== overIndex) {
updateItems((items) => arrayMove(items, activeIndex, overIndex));
}
}
}}
onDragCancel={() => setActiveId(null)}
>
<SortableContext items={values} strategy={verticalListSortingStrategy}>
<Stack gap="xs">
<>
{values.map((value, index) => {
const item = getItem(value);
const removeItem = () => {
form.setValues((previous) => {
const previousValues = previous.options?.[property] as TOptionValue[];
return {
...previous,
options: {
...previous.options,
[property]: previousValues.filter((id) => id !== value),
},
};
});
};
if (!item) {
return null;
}
return (
<MemoizedItem
key={value}
id={value}
index={index}
item={item}
removeItem={removeItem}
options={options}
/>
);
})}
{isLoading && (
<Center h={256}>
<Loader />
</Center>
)}
{error && <Center h={256}>{JSON.stringify(error)}</Center>}
</>
</Stack>
</SortableContext>
</DndContext>
</Stack>
</Fieldset>
);
};
interface ItemProps<TItem, TOptionValue extends UniqueIdentifier> {
id: TOptionValue;
item: TItem;
index: number;
removeItem: () => void;
options: CommonWidgetInputProps<"sortableItemList">["options"];
}
const Item = <TItem, TOptionValue extends UniqueIdentifier>({
id,
index,
item,
removeItem,
options,
}: ItemProps<TItem, TOptionValue>) => {
const { attributes, isDragging, listeners, setNodeRef, setActivatorNodeRef, transform, transition } = useSortable({
id,
});
const Handle = (props: Partial<ActionIconProps>) => {
return (
<ActionIcon
variant="transparent"
color="gray"
{...props}
{...listeners}
ref={setActivatorNodeRef}
style={{ cursor: "grab" }}
>
<IconGripHorizontal />
</ActionIcon>
);
};
return (
<Card
withBorder
shadow="xs"
padding="sm"
radius="md"
style={
{
transition: [transition].filter(Boolean).join(", "),
"--translate-x": transform ? `${Math.round(transform.x)}px` : undefined,
"--translate-y": transform ? `${Math.round(transform.y)}px` : undefined,
"--scale-x": transform?.scaleX ? `${transform.scaleX}` : undefined,
"--scale-y": transform?.scaleY ? `${transform.scaleY}` : undefined,
"--index": index,
transform:
"translate3d(var(--translate-x, 0), var(--translate-y, 0), 0) scaleX(var(--scale-x, 1)) scaleY(var(--scale-y, 1))",
transformOrigin: "0 0",
...(isDragging
? {
opacity: "var(--dragging-opacity, 0.5)",
zIndex: 0,
}
: {}),
} as React.CSSProperties
}
ref={setNodeRef}
>
<options.itemComponent
key={index}
item={item}
removeItem={removeItem}
rootAttributes={attributes}
handle={Handle}
/>
</Card>
);
};
const MemoizedItem = memo(Item);

View File

@@ -0,0 +1,114 @@
"use client";
import { memo, useState } from "react";
import type { SelectProps } from "@mantine/core";
import { Button, Group, Loader, Select, Stack } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
interface InnerProps {
presentAppIds: string[];
onSelect: (props: RouterOutputs["app"]["selectable"][number]) => void | Promise<void>;
confirmLabel?: string;
}
interface AppSelectFormType {
id: string;
}
export const AppSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n();
const { data: apps, isPending } = clientApi.app.selectable.useQuery();
const [loading, setLoading] = useState(false);
const form = useForm<AppSelectFormType>();
const handleSubmitAsync = async (values: AppSelectFormType) => {
const currentApp = apps?.find((app) => app.id === values.id);
if (!currentApp) return;
setLoading(true);
await innerProps.onSelect(currentApp);
setLoading(false);
actions.closeModal();
};
const confirmLabel = innerProps.confirmLabel ?? t("common.action.add");
const currentApp = apps?.find((app) => app.id === form.values.id);
return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack>
<Select
{...form.getInputProps("id")}
label={t("app.action.select.label")}
searchable
clearable
leftSection={<MemoizedLeftSection isPending={isPending} currentApp={currentApp} />}
nothingFoundMessage={t("app.action.select.notFound")}
renderOption={renderSelectOption}
limit={5}
data={
apps
?.filter((app) => !innerProps.presentAppIds.includes(app.id))
.map((app) => ({
label: app.name,
value: app.id,
iconUrl: app.iconUrl,
})) ?? []
}
/>
<Group justify="end">
<Button variant="default" onClick={actions.closeModal}>
{t("common.action.cancel")}
</Button>
<Button type="submit" loading={loading}>
{confirmLabel}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("app.action.select.label"),
});
const iconProps = {
stroke: 1.5,
color: "currentColor",
opacity: 0.6,
size: 18,
};
const renderSelectOption: SelectProps["renderOption"] = ({ option, checked }) => (
<Group flex="1" gap="xs">
{"iconUrl" in option && typeof option.iconUrl === "string" ? (
<img width={20} height={20} src={option.iconUrl} alt={option.label} />
) : null}
{option.label}
{checked && <IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />}
</Group>
);
interface LeftSectionProps {
isPending: boolean;
currentApp: RouterOutputs["app"]["selectable"][number] | undefined;
}
const size = 20;
const LeftSection = ({ isPending, currentApp }: LeftSectionProps) => {
if (isPending) {
return <Loader size={size} />;
}
if (currentApp) {
return <img width={size} height={size} src={currentApp.iconUrl} alt={currentApp.name} />;
}
return null;
};
const MemoizedLeftSection = memo(LeftSection);

View File

@@ -0,0 +1,3 @@
.card:hover {
background-color: var(--mantine-color-primaryColor-light-hover);
}

View File

@@ -0,0 +1,160 @@
"use client";
import { Anchor, Box, Card, Divider, Flex, Group, Stack, Text, Title, UnstyledButton } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { WidgetComponentProps } from "../definition";
import classes from "./bookmark.module.css";
export default function BookmarksWidget({ options, width, height }: WidgetComponentProps<"bookmarks">) {
const [data] = clientApi.app.byIds.useSuspenseQuery(options.items, {
select(data) {
return data.sort((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
},
});
return (
<Stack h="100%" gap="sm" p="sm">
<Title order={4} px="0.25rem">
{options.title}
</Title>
{options.layout === "grid" && <GridLayout data={data} width={width} height={height} />}
{options.layout !== "grid" && <FlexLayout data={data} direction={options.layout} />}
</Stack>
);
}
interface FlexLayoutProps {
data: RouterOutputs["app"]["byIds"];
direction: "row" | "column";
}
const FlexLayout = ({ data, direction }: FlexLayoutProps) => {
return (
<Flex direction={direction} gap="0" h="100%" w="100%">
{data.map((app, index) => (
<div key={app.id} style={{ display: "flex", flex: "1", flexDirection: direction }}>
<Divider
m="3px"
orientation={direction !== "column" ? "vertical" : "horizontal"}
color={index === 0 ? "transparent" : undefined}
/>
<UnstyledButton
component="a"
href={app.href ?? undefined}
target="_blank"
rel="noopener noreferrer"
key={app.id}
h="100%"
w="100%"
>
<Card
radius="md"
style={{ containerType: "size" }}
className={classes.card}
h="100%"
w="100%"
display="flex"
p={0}
>
{direction === "row" ? <VerticalItem app={app} /> : <HorizontalItem app={app} />}
</Card>
</UnstyledButton>
</div>
))}
</Flex>
);
};
interface GridLayoutProps {
data: RouterOutputs["app"]["byIds"];
width: number;
height: number;
}
const GridLayout = ({ data, width, height }: GridLayoutProps) => {
// Calculates the perfect number of columns for the grid layout based on the width and height in pixels and the number of items
const columns = Math.ceil(Math.sqrt(data.length * (width / height)));
return (
<Box
display="grid"
h="100%"
style={{
gridTemplateColumns: `repeat(${columns}, auto)`,
gap: 10,
}}
>
{data.map((app) => (
<UnstyledButton
component="a"
href={app.href ?? undefined}
target="_blank"
rel="noopener noreferrer"
key={app.id}
h="100%"
>
<Card withBorder style={{ containerType: "size" }} h="100%" className={classes.card} p="5cqmin">
<VerticalItem app={app} />
</Card>
</UnstyledButton>
))}
</Box>
);
};
const VerticalItem = ({ app }: { app: RouterOutputs["app"]["byIds"][number] }) => {
return (
<Stack h="100%" gap="5cqmin">
<Text fw={700} ta="center" size="20cqmin">
{app.name}
</Text>
<img
style={{
maxHeight: "100%",
maxWidth: "100%",
overflow: "auto",
flex: 1,
objectFit: "contain",
scale: 0.8,
}}
src={app.iconUrl}
alt={app.name}
/>
<Anchor ta="center" component="span" size="12cqmin">
{app.href ? new URL(app.href).hostname : undefined}
</Anchor>
</Stack>
);
};
const HorizontalItem = ({ app }: { app: RouterOutputs["app"]["byIds"][number] }) => {
return (
<Group wrap="nowrap">
<img
style={{
overflow: "auto",
objectFit: "contain",
scale: 0.8,
minHeight: "100cqh",
maxHeight: "100cqh",
minWidth: "100cqh",
maxWidth: "100cqh",
}}
src={app.iconUrl}
alt={app.name}
/>
<Stack justify="space-between" gap={0}>
<Text fw={700} size="45cqh" lineClamp={1}>
{app.name}
</Text>
<Anchor component="span" size="30cqh">
{app.href ? new URL(app.href).hostname : undefined}
</Anchor>
</Stack>
</Group>
);
};

View File

@@ -0,0 +1,67 @@
import { ActionIcon, Avatar, Button, Group, Stack, Text } from "@mantine/core";
import { IconClock, IconX } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
import { AppSelectModal } from "./app-select-modal";
export const { definition, componentLoader } = createWidgetDefinition("bookmarks", {
icon: IconClock,
options: optionsBuilder.from((factory) => ({
title: factory.text(),
layout: factory.select({
options: (["grid", "row", "column"] as const).map((value) => ({
value,
label: (t) => t(`widget.bookmarks.option.layout.option.${value}.label`),
})),
defaultValue: "column",
}),
items: factory.sortableItemList<RouterOutputs["app"]["all"][number], string>({
ItemComponent: ({ item, handle: Handle, removeItem, rootAttributes }) => {
return (
<Group {...rootAttributes} tabIndex={0} justify="space-between" wrap="nowrap">
<Group wrap="nowrap">
<Handle />
<Group>
<Avatar src={item.iconUrl} alt={item.name} />
<Stack gap={0}>
<Text>{item.name}</Text>
</Stack>
</Group>
</Group>
<ActionIcon variant="transparent" color="red" onClick={removeItem}>
<IconX size={20} />
</ActionIcon>
</Group>
);
},
AddButton({ addItem, values }) {
const { openModal } = useModalAction(AppSelectModal);
const t = useI18n();
return (
<Button onClick={() => openModal({ onSelect: addItem, presentAppIds: values })}>
{t("widget.bookmarks.option.items.add")}
</Button>
);
},
uniqueIdentifier: (item) => item.id,
useData: (initialIds) => {
const { data, error, isLoading } = clientApi.app.byIds.useQuery(initialIds);
return {
data,
error,
isLoading,
};
},
}),
})),
}).withDynamicImport(() => import("./component"));

View File

@@ -6,6 +6,7 @@ import { Center, Loader as UiLoader } from "@mantine/core";
import type { WidgetKind } from "@homarr/definitions";
import * as app from "./app";
import * as bookmarks from "./bookmarks";
import * as calendar from "./calendar";
import * as clock from "./clock";
import type { WidgetComponentProps } from "./definition";
@@ -49,6 +50,7 @@ export const widgetImports = {
"mediaRequests-requestList": mediaRequestsList,
"mediaRequests-requestStats": mediaRequestsStats,
rssFeed,
bookmarks,
indexerManager,
healthMonitoring,
} satisfies WidgetImportRecord;

View File

@@ -106,7 +106,15 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
return null;
}
return <Input key={key} kind={innerProps.kind} property={key} options={value as never} />;
return (
<Input
key={key}
kind={innerProps.kind}
property={key}
options={value as never}
initialOptions={innerProps.value.options}
/>
);
})}
<Group justify="space-between">
<Button

View File

@@ -1,3 +1,7 @@
import type React from "react";
import type { DraggableAttributes, UniqueIdentifier } from "@dnd-kit/core";
import type { ActionIconProps } from "@mantine/core";
import { objectEntries } from "@homarr/common";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import type { ZodType } from "@homarr/validation";
@@ -21,6 +25,19 @@ interface MultiSelectInput<TOptions extends SelectOption[]>
searchable?: boolean;
}
interface SortableItemListInput<TItem, TOptionValue extends UniqueIdentifier>
extends Omit<CommonInput<TOptionValue[]>, "withDescription"> {
AddButton: (props: { addItem: (item: TItem) => void; values: TOptionValue[] }) => React.ReactNode;
ItemComponent: (props: {
item: TItem;
removeItem: () => void;
rootAttributes: DraggableAttributes;
handle: (props: Partial<Pick<ActionIconProps, "size" | "color" | "variant">>) => React.ReactNode;
}) => React.ReactNode;
uniqueIdentifier: (item: TItem) => TOptionValue;
useData: (values: TOptionValue[]) => { data: TItem[] | undefined; isLoading: boolean; error: unknown };
}
interface SelectInput<TOptions extends readonly SelectOption[]>
extends CommonInput<inferSelectOptionValue<TOptions[number]>> {
options: TOptions;
@@ -109,10 +126,26 @@ const optionsFactory = {
defaultValue: "",
withDescription: false,
}),
sortableItemList: <const TItem, const TOptionValue extends UniqueIdentifier>(
input: SortableItemListInput<TItem, TOptionValue>,
) => ({
type: "sortableItemList" as const,
defaultValue: [] as TOptionValue[],
itemComponent: input.ItemComponent,
addButton: input.AddButton,
uniqueIdentifier: input.uniqueIdentifier,
useData: input.useData,
withDescription: false,
}),
};
type WidgetOptionFactory = typeof optionsFactory;
export type WidgetOptionDefinition = ReturnType<WidgetOptionFactory[keyof WidgetOptionFactory]>;
export type WidgetOptionDefinition =
| ReturnType<WidgetOptionFactory[Exclude<keyof WidgetOptionFactory, "sortableItemList">]>
// We allow any here as it's already type guarded with Record<string, unknown> and it still infers the correct type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
| ReturnType<typeof optionsFactory.sortableItemList<any, any>>;
export type WidgetOptionsRecord = Record<string, WidgetOptionDefinition>;
export type WidgetOptionType = WidgetOptionDefinition["type"];
export type WidgetOptionOfType<TType extends WidgetOptionType> = Extract<WidgetOptionDefinition, { type: TType }>;