Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcc9661790 | ||
|
|
b7a390451d | ||
|
|
b7d72d2106 | ||
|
|
471aa0a49f | ||
|
|
09ecb3f0b0 |
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: "Bookmarks"
|
title: "Bookmarks"
|
||||||
description: "Displays multiple app links"
|
description: "Keeps useful links together"
|
||||||
hide_title: true
|
hide_title: true
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -13,11 +13,12 @@ import { bookmarksWidget } from ".";
|
|||||||
|
|
||||||
<WidgetHeader widget={bookmarksWidget} categories={["Links", "Apps"]} />
|
<WidgetHeader widget={bookmarksWidget} categories={["Links", "Apps"]} />
|
||||||
|
|
||||||
The bookmarks widget allows you to display multiple links in a single widget.
|
The bookmarks widget keeps direct URLs and Homarr Apps in one ordered list.
|
||||||
Select one or more existing Apps directly in the widget settings; these bookmarks keep a reference to the App, so changes to its name, icon, or URL are reflected automatically.
|
Paste a URL and press Enter, paste several comma-separated or one-per-line URLs, or search for an existing App.
|
||||||
For links that do not need an App, add their URLs under **Custom links** instead.
|
URLs do not need an App record; Homarr uses the site's favicon and falls back to a link icon when it cannot load one.
|
||||||
Its normal view adapts each configured layout to the available width and height: lower-priority hostnames are hidden before names or icons, and larger collections scroll instead of crushing cards.
|
Bookmarks selected from Apps continue to use the App name, URL, icon, and description.
|
||||||
Advanced view shows every bookmark as a roomy card with its full title, hostname, icon, and complete description regardless of the compact layout and visibility choices.
|
|
||||||
|
**Adaptive** layout is the practical default: it changes between icons, compact rows, and tiles as the widget changes shape. Every compact item exposes its destination on hover, while advanced view expands items into descriptive cards.
|
||||||
|
|
||||||
### Screenshots
|
### Screenshots
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { IconBookmark } from "@tabler/icons-react";
|
|||||||
export const bookmarksWidget: WidgetDefinition = {
|
export const bookmarksWidget: WidgetDefinition = {
|
||||||
icon: IconBookmark,
|
icon: IconBookmark,
|
||||||
name: "Bookmarks",
|
name: "Bookmarks",
|
||||||
description: "Displays multiple app links",
|
description: "Keeps useful links together",
|
||||||
path: "../../widgets/bookmarks",
|
path: "../../widgets/bookmarks",
|
||||||
configuration: {
|
configuration: {
|
||||||
items: [
|
items: [
|
||||||
@@ -16,9 +16,21 @@ export const bookmarksWidget: WidgetDefinition = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Layout",
|
name: "Layout",
|
||||||
description: "Layout in which the bookmarks are displayed.",
|
description: "How bookmarks use the available widget space.",
|
||||||
values: { type: "select", options: ["Vertical", "Horizontal", "Grid", "Grid horizontal"] },
|
values: { type: "select", options: ["Adaptive", "Vertical", "Horizontal", "Grid", "Compact grid", "Icons"] },
|
||||||
defaultValue: "Vertical",
|
defaultValue: "Adaptive",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Appearance",
|
||||||
|
description: "Card surface style.",
|
||||||
|
values: { type: "select", options: ["Soft", "Filled", "Outline", "Plain"] },
|
||||||
|
defaultValue: "Soft",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Card spacing",
|
||||||
|
description: "Space between bookmark cards.",
|
||||||
|
values: { type: "select", options: ["Extra small", "Small", "Medium", "Large", "Extra large"] },
|
||||||
|
defaultValue: "Extra small",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Hide title",
|
name: "Hide title",
|
||||||
@@ -46,16 +58,10 @@ export const bookmarksWidget: WidgetDefinition = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Bookmarks",
|
name: "Bookmarks",
|
||||||
description: "List of bookmarks to display in the widget.",
|
description: "URLs and existing Apps shown by the widget.",
|
||||||
values: "Select one or more Apps directly, then order them as you like.",
|
values: "Paste one or many URLs, or find an existing App. Drag to reorder.",
|
||||||
defaultValue: "No bookmarks selected",
|
defaultValue: "No bookmarks selected",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "Custom links",
|
|
||||||
description: "URLs to display without first creating an App.",
|
|
||||||
values: "List of URLs.",
|
|
||||||
defaultValue: "No custom links",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -2192,8 +2192,51 @@
|
|||||||
"layout": {
|
"layout": {
|
||||||
"label": "Layout",
|
"label": "Layout",
|
||||||
"option": {
|
"option": {
|
||||||
|
"adaptive": {
|
||||||
|
"label": "Adaptive"
|
||||||
|
},
|
||||||
"gridHorizontal": {
|
"gridHorizontal": {
|
||||||
"label": "Grid horizontal"
|
"label": "Compact grid"
|
||||||
|
},
|
||||||
|
"icons": {
|
||||||
|
"label": "Icons"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"variant": {
|
||||||
|
"label": "Appearance",
|
||||||
|
"option": {
|
||||||
|
"soft": {
|
||||||
|
"label": "Soft"
|
||||||
|
},
|
||||||
|
"filled": {
|
||||||
|
"label": "Filled"
|
||||||
|
},
|
||||||
|
"outline": {
|
||||||
|
"label": "Outline"
|
||||||
|
},
|
||||||
|
"plain": {
|
||||||
|
"label": "Plain"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"spacing": {
|
||||||
|
"label": "Card spacing",
|
||||||
|
"option": {
|
||||||
|
"xs": {
|
||||||
|
"label": "Extra small"
|
||||||
|
},
|
||||||
|
"sm": {
|
||||||
|
"label": "Small"
|
||||||
|
},
|
||||||
|
"md": {
|
||||||
|
"label": "Medium"
|
||||||
|
},
|
||||||
|
"lg": {
|
||||||
|
"label": "Large"
|
||||||
|
},
|
||||||
|
"xl": {
|
||||||
|
"label": "Extra large"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2210,17 +2253,21 @@
|
|||||||
"label": "Show border"
|
"label": "Show border"
|
||||||
},
|
},
|
||||||
"customUrls": {
|
"customUrls": {
|
||||||
"label": "Custom links",
|
"label": "Custom links"
|
||||||
"description": "Add URLs directly without creating an App first"
|
|
||||||
},
|
},
|
||||||
"items": {
|
"items": {
|
||||||
"label": "Bookmarks",
|
"label": "Bookmarks",
|
||||||
|
"remove": "Remove bookmark",
|
||||||
"apps": {
|
"apps": {
|
||||||
"label": "Apps",
|
"label": "Add bookmarks",
|
||||||
"placeholder": "Select one or more apps"
|
"description": "Paste URLs or find existing Apps. Drag the bookmarks below to reorder them.",
|
||||||
|
"placeholder": "Paste URLs or search Apps",
|
||||||
|
"addUrl": "Add this URL",
|
||||||
|
"hint": "Press Enter to add a URL. Paste comma-separated or one-per-line URLs to add several."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"empty": "Add a URL or App in the widget settings"
|
||||||
},
|
},
|
||||||
"archiveTeamWarrior": {
|
"archiveTeamWarrior": {
|
||||||
"name": "ArchiveTeam Warrior",
|
"name": "ArchiveTeam Warrior",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const theme = createTheme({
|
|||||||
},
|
},
|
||||||
|
|
||||||
fontSizes: {
|
fontSizes: {
|
||||||
|
xxs: rem(11),
|
||||||
xs: rem(12),
|
xs: rem(12),
|
||||||
sm: rem(14),
|
sm: rem(14),
|
||||||
md: rem(16),
|
md: rem(16),
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export interface CommonWidgetInputProps<TKey extends WidgetOptionType> {
|
|||||||
boardId?: string;
|
boardId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type UseWidgetInputTranslationReturnType = (key: "label" | "description" | "placeholder") => string;
|
type UseWidgetInputTranslationReturnType = (key: "label" | "description" | "placeholder" | "remove") => string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Short description why as and unknown convertions are used below:
|
* Short description why as and unknown convertions are used below:
|
||||||
|
|||||||
@@ -96,6 +96,28 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
|
|||||||
updateItems((currentValues) => [...currentValues, options.uniqueIdentifier(item) as TOptionValue]);
|
updateItems((currentValues) => [...currentValues, options.uniqueIdentifier(item) as TOptionValue]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const migrateItems = useCallback(
|
||||||
|
(items: TItem[], optionsPatch: Record<string, unknown>) => {
|
||||||
|
const migratedEntries = items.map((item) => [options.uniqueIdentifier(item) as TOptionValue, item] as const);
|
||||||
|
setTempMap((previous) => {
|
||||||
|
const next = new Map(previous);
|
||||||
|
for (const [value, item] of migratedEntries) next.set(value, item);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
form.setFieldValue("options", (currentOptions) => {
|
||||||
|
const currentItems = Array.isArray(currentOptions[property])
|
||||||
|
? (currentOptions[property] as TOptionValue[])
|
||||||
|
: options.defaultValue;
|
||||||
|
const nextItems = [...currentItems];
|
||||||
|
for (const [value] of migratedEntries) {
|
||||||
|
if (!nextItems.includes(value)) nextItems.push(value);
|
||||||
|
}
|
||||||
|
return { ...currentOptions, ...optionsPatch, [property]: nextItems };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[form, options, property],
|
||||||
|
);
|
||||||
|
|
||||||
const removeItem = useCallback(
|
const removeItem = useCallback(
|
||||||
(value: TOptionValue) => {
|
(value: TOptionValue) => {
|
||||||
updateItems((currentValues) => currentValues.filter((candidate) => candidate !== value));
|
updateItems((currentValues) => currentValues.filter((candidate) => candidate !== value));
|
||||||
@@ -112,7 +134,13 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
|
|||||||
return (
|
return (
|
||||||
<Fieldset legend={t("label")}>
|
<Fieldset legend={t("label")}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<options.addButton addItem={addItem} removeItem={removeItem} values={values} />
|
<options.addButton
|
||||||
|
addItem={addItem}
|
||||||
|
migrateItems={migrateItems}
|
||||||
|
removeItem={removeItem}
|
||||||
|
values={values}
|
||||||
|
initialOptions={initialOptions}
|
||||||
|
/>
|
||||||
|
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
@@ -153,6 +181,7 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
|
|||||||
index={index}
|
index={index}
|
||||||
item={item}
|
item={item}
|
||||||
removeItem={() => removeItem(value)}
|
removeItem={() => removeItem(value)}
|
||||||
|
removeLabel={t("remove")}
|
||||||
options={options}
|
options={options}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -177,6 +206,7 @@ interface ItemProps<TItem, TOptionValue extends UniqueIdentifier> {
|
|||||||
item: TItem;
|
item: TItem;
|
||||||
index: number;
|
index: number;
|
||||||
removeItem: () => void;
|
removeItem: () => void;
|
||||||
|
removeLabel: string;
|
||||||
options: CommonWidgetInputProps<"sortableItemList">["options"];
|
options: CommonWidgetInputProps<"sortableItemList">["options"];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +215,7 @@ const Item = <TItem, TOptionValue extends UniqueIdentifier>({
|
|||||||
index,
|
index,
|
||||||
item,
|
item,
|
||||||
removeItem,
|
removeItem,
|
||||||
|
removeLabel,
|
||||||
options,
|
options,
|
||||||
}: ItemProps<TItem, TOptionValue>) => {
|
}: ItemProps<TItem, TOptionValue>) => {
|
||||||
const { attributes, isDragging, listeners, setNodeRef, setActivatorNodeRef, transform, transition } = useSortable({
|
const { attributes, isDragging, listeners, setNodeRef, setActivatorNodeRef, transform, transition } = useSortable({
|
||||||
@@ -220,6 +251,7 @@ const Item = <TItem, TOptionValue extends UniqueIdentifier>({
|
|||||||
key={index}
|
key={index}
|
||||||
item={item}
|
item={item}
|
||||||
removeItem={removeItem}
|
removeItem={removeItem}
|
||||||
|
removeLabel={removeLabel}
|
||||||
rootAttributes={attributes}
|
rootAttributes={attributes}
|
||||||
handle={
|
handle={
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
|
|||||||
@@ -1,79 +1,198 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { MultiSelectProps } from "@mantine/core";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Group, Loader, MultiSelect } from "@mantine/core";
|
import {
|
||||||
import { IconCheck } from "@tabler/icons-react";
|
Avatar,
|
||||||
|
Combobox,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
PillsInput,
|
||||||
|
ScrollArea,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
useCombobox,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconCheck, IconLink, IconPlus } from "@tabler/icons-react";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import type { SortableItemListInput } from "../options";
|
import type { SortableItemListInput } from "../options";
|
||||||
|
import { createDirectBookmark, getBookmarkFaviconUrl, splitBookmarkUrls } from "./bookmark-item";
|
||||||
|
import type { BookmarkItem } from "./bookmark-item";
|
||||||
|
|
||||||
type SelectableApp = RouterOutputs["app"]["selectable"][number];
|
type SelectableApp = RouterOutputs["app"]["selectable"][number];
|
||||||
|
export type BookmarkSelectionItem = SelectableApp | BookmarkItem;
|
||||||
|
|
||||||
export const BookmarkAddButton: SortableItemListInput<SelectableApp, string>["AddButton"] = ({
|
const createOptionValue = "$create-url";
|
||||||
|
|
||||||
|
export const BookmarkAddButton: SortableItemListInput<BookmarkSelectionItem, string>["AddButton"] = ({
|
||||||
addItem,
|
addItem,
|
||||||
removeItem,
|
migrateItems,
|
||||||
values,
|
values,
|
||||||
|
initialOptions,
|
||||||
}) => {
|
}) => {
|
||||||
const t = useI18n("widget.bookmarks.option.items.apps");
|
const t = useI18n("widget.bookmarks.option.items.apps");
|
||||||
const tCommon = useI18n("common");
|
const tCommon = useI18n("common");
|
||||||
const tSelect = useI18n("app.action.select");
|
const tSelect = useI18n("app.action.select");
|
||||||
const { data: apps = [], isPending, error } = clientApi.app.selectable.useQuery();
|
const { data: apps = [], isPending, error } = clientApi.app.selectable.useQuery();
|
||||||
const appsById = new Map(apps.map((app) => [app.id, app]));
|
const [search, setSearch] = useState("");
|
||||||
|
const combobox = useCombobox({
|
||||||
|
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||||
|
onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"),
|
||||||
|
});
|
||||||
|
const selectedValues = useMemo(() => new Set(values), [values]);
|
||||||
|
const migrationStartedRef = useRef(false);
|
||||||
|
const legacyUrls = useMemo(() => {
|
||||||
|
if (!Array.isArray(initialOptions.customUrls)) return [];
|
||||||
|
return initialOptions.customUrls.filter((value): value is string => typeof value === "string");
|
||||||
|
}, [initialOptions.customUrls]);
|
||||||
|
|
||||||
const handleChange = (nextValues: string[]) => {
|
useEffect(() => {
|
||||||
const nextIds = new Set(nextValues);
|
if (migrationStartedRef.current || legacyUrls.length === 0) return;
|
||||||
for (const value of values) {
|
|
||||||
if (!nextIds.has(value)) removeItem(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentIds = new Set(values);
|
migrationStartedRef.current = true;
|
||||||
for (const value of nextValues) {
|
const legacyItems = legacyUrls.flatMap((url) => createDirectBookmark(url) ?? []);
|
||||||
if (currentIds.has(value)) continue;
|
migrateItems(legacyItems, { customUrls: [] });
|
||||||
const app = appsById.get(value);
|
}, [legacyUrls, migrateItems]);
|
||||||
if (app) addItem(app);
|
|
||||||
|
const normalizedSearch = search.trim().toLowerCase();
|
||||||
|
const filteredApps = apps.filter((app) => {
|
||||||
|
if (selectedValues.has(app.id)) return false;
|
||||||
|
if (normalizedSearch.length === 0) return true;
|
||||||
|
|
||||||
|
return app.name.toLowerCase().includes(normalizedSearch) || app.href?.toLowerCase().includes(normalizedSearch);
|
||||||
|
});
|
||||||
|
const pendingUrl = createDirectBookmark(search);
|
||||||
|
|
||||||
|
const addUrls = (input: string) => {
|
||||||
|
const nextValues = new Set(values);
|
||||||
|
for (const value of splitBookmarkUrls(input)) {
|
||||||
|
const item = createDirectBookmark(value);
|
||||||
|
if (!item || nextValues.has(item.id)) continue;
|
||||||
|
addItem(item);
|
||||||
|
nextValues.add(item.id);
|
||||||
}
|
}
|
||||||
|
setSearch("");
|
||||||
|
combobox.closeDropdown();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOptionSubmit = (value: string) => {
|
||||||
|
if (value === createOptionValue) {
|
||||||
|
addUrls(search);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = apps.find((candidate) => candidate.id === value);
|
||||||
|
if (app) addItem(app);
|
||||||
|
setSearch("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = filteredApps.map((app) => (
|
||||||
|
<Combobox.Option value={app.id} key={app.id}>
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<Avatar src={app.iconUrl} name={app.name} color="gray" size={28} radius="sm">
|
||||||
|
<IconLink size={16} />
|
||||||
|
</Avatar>
|
||||||
|
<Stack gap={0} miw={0} flex={1}>
|
||||||
|
<Text size="sm" fw={600} truncate>
|
||||||
|
{app.name}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" truncate>
|
||||||
|
{app.href}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<IconPlus size={16} aria-hidden />
|
||||||
|
</Group>
|
||||||
|
</Combobox.Option>
|
||||||
|
));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MultiSelect
|
<Combobox
|
||||||
label={t("label")}
|
store={combobox}
|
||||||
placeholder={t("placeholder")}
|
onOptionSubmit={handleOptionSubmit}
|
||||||
value={values}
|
position="bottom-start"
|
||||||
onChange={handleChange}
|
middlewares={{ flip: true, shift: true }}
|
||||||
searchable
|
styles={{
|
||||||
clearable
|
dropdown: { overflow: "hidden" },
|
||||||
clearSearchOnChange
|
option: { borderRadius: "var(--mantine-radius-sm)" },
|
||||||
hidePickedOptions
|
}}
|
||||||
disabled={isPending || Boolean(error)}
|
>
|
||||||
error={error ? tCommon("error") : undefined}
|
<Combobox.DropdownTarget>
|
||||||
rightSection={isPending ? <Loader size="xs" /> : undefined}
|
<PillsInput
|
||||||
nothingFoundMessage={tSelect("notFound")}
|
label={t("label")}
|
||||||
renderOption={renderAppOption}
|
description={t("description")}
|
||||||
data={apps.map((app) => ({
|
error={error ? tCommon("error") : undefined}
|
||||||
value: app.id,
|
onClick={() => combobox.openDropdown()}
|
||||||
label: app.name,
|
leftSection={
|
||||||
iconUrl: app.iconUrl,
|
<ThemeIcon variant="light" size="sm" radius="xl">
|
||||||
}))}
|
<IconLink size={14} />
|
||||||
/>
|
</ThemeIcon>
|
||||||
|
}
|
||||||
|
rightSection={isPending ? <Loader size="xs" /> : undefined}
|
||||||
|
styles={{ input: { minHeight: 44 } }}
|
||||||
|
>
|
||||||
|
<Combobox.EventsTarget>
|
||||||
|
<PillsInput.Field
|
||||||
|
value={search}
|
||||||
|
placeholder={t("placeholder")}
|
||||||
|
onFocus={() => combobox.openDropdown()}
|
||||||
|
onBlur={() => combobox.closeDropdown()}
|
||||||
|
onChange={(event) => {
|
||||||
|
setSearch(event.currentTarget.value);
|
||||||
|
combobox.openDropdown();
|
||||||
|
combobox.updateSelectedOptionIndex();
|
||||||
|
}}
|
||||||
|
onPaste={(event) => {
|
||||||
|
const pastedValue = event.clipboardData.getData("text");
|
||||||
|
if (splitBookmarkUrls(pastedValue).length < 2) return;
|
||||||
|
event.preventDefault();
|
||||||
|
addUrls(pastedValue);
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== "Enter") return;
|
||||||
|
if (!pendingUrl) return;
|
||||||
|
event.preventDefault();
|
||||||
|
addUrls(search);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Combobox.EventsTarget>
|
||||||
|
</PillsInput>
|
||||||
|
</Combobox.DropdownTarget>
|
||||||
|
|
||||||
|
<Combobox.Dropdown>
|
||||||
|
<ScrollArea.Autosize mah={280} type="auto">
|
||||||
|
<Combobox.Options>
|
||||||
|
{pendingUrl && !selectedValues.has(pendingUrl.id) ? (
|
||||||
|
<Combobox.Option value={createOptionValue}>
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<Avatar src={getBookmarkFaviconUrl(pendingUrl.href)} size={28} radius="sm" color="gray">
|
||||||
|
<IconLink size={16} />
|
||||||
|
</Avatar>
|
||||||
|
<Stack gap={0} miw={0} flex={1}>
|
||||||
|
<Text size="sm" fw={600} truncate>
|
||||||
|
{t("addUrl")}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" truncate>
|
||||||
|
{pendingUrl.href}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<IconCheck size={16} aria-hidden />
|
||||||
|
</Group>
|
||||||
|
</Combobox.Option>
|
||||||
|
) : null}
|
||||||
|
{options}
|
||||||
|
{!pendingUrl && options.length === 0 ? <Combobox.Empty>{tSelect("notFound")}</Combobox.Empty> : null}
|
||||||
|
</Combobox.Options>
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
<Combobox.Footer>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t("hint")}
|
||||||
|
</Text>
|
||||||
|
</Combobox.Footer>
|
||||||
|
</Combobox.Dropdown>
|
||||||
|
</Combobox>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const iconProps = {
|
|
||||||
stroke: 1.5,
|
|
||||||
color: "currentColor",
|
|
||||||
opacity: 0.6,
|
|
||||||
size: 18,
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderAppOption: MultiSelectProps["renderOption"] = ({ option, checked }) => (
|
|
||||||
<Group flex="1" gap="xs">
|
|
||||||
{"iconUrl" in option && typeof option.iconUrl === "string" ? (
|
|
||||||
<img width={20} height={20} src={option.iconUrl} alt="" />
|
|
||||||
) : null}
|
|
||||||
{option.label}
|
|
||||||
{checked && <IconCheck style={{ marginInlineStart: "auto" }} {...iconProps} />}
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { getSafeApplicationUrl } from "../common/application-url";
|
||||||
|
|
||||||
|
export const directBookmarkPrefix = "url:";
|
||||||
|
|
||||||
|
export interface BookmarkItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
iconUrl?: string;
|
||||||
|
href: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const normalizeBookmarkUrl = (value: string): string | undefined => {
|
||||||
|
const trimmedValue = value.trim();
|
||||||
|
if (trimmedValue.length === 0) return undefined;
|
||||||
|
|
||||||
|
const directUrl = getSafeApplicationUrl(trimmedValue);
|
||||||
|
if (directUrl) return directUrl;
|
||||||
|
|
||||||
|
if (/^(?:localhost(?::\d+)?|(?:[\w-]+\.)+[\w-]+(?::\d+)?)(?:[/?#].*)?$/i.test(trimmedValue)) {
|
||||||
|
return getSafeApplicationUrl(`https://${trimmedValue}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDirectBookmarkValue = (url: string) => `${directBookmarkPrefix}${url}`;
|
||||||
|
|
||||||
|
export const getDirectBookmarkUrl = (value: string): string | undefined => {
|
||||||
|
if (!value.startsWith(directBookmarkPrefix)) return undefined;
|
||||||
|
return normalizeBookmarkUrl(value.slice(directBookmarkPrefix.length));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBookmarkFaviconUrl = (href: string | null): string | undefined => {
|
||||||
|
const safeUrl = getSafeApplicationUrl(href);
|
||||||
|
if (!safeUrl) return undefined;
|
||||||
|
|
||||||
|
const url = new URL(safeUrl);
|
||||||
|
return `${url.origin}/favicon.ico`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createDirectBookmark = (url: string): BookmarkItem | undefined => {
|
||||||
|
const href = normalizeBookmarkUrl(url);
|
||||||
|
if (!href) return undefined;
|
||||||
|
|
||||||
|
const parsedUrl = new URL(href);
|
||||||
|
return {
|
||||||
|
id: getDirectBookmarkValue(href),
|
||||||
|
name: parsedUrl.hostname,
|
||||||
|
description: null,
|
||||||
|
href,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const splitBookmarkUrls = (value: string) =>
|
||||||
|
value.split(/\r?\n+/).flatMap((line) => {
|
||||||
|
const trimmedLine = line.trim();
|
||||||
|
if (trimmedLine.length === 0) return [];
|
||||||
|
|
||||||
|
const commaSeparatedValues = trimmedLine.split(",").map((part) => part.trim());
|
||||||
|
if (commaSeparatedValues.length > 1 && commaSeparatedValues.every((part) => normalizeBookmarkUrl(part))) {
|
||||||
|
return commaSeparatedValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [trimmedLine];
|
||||||
|
});
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
.card {
|
|
||||||
background-color: transparent;
|
|
||||||
border-color: rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.38));
|
|
||||||
transition:
|
|
||||||
background-color 120ms ease,
|
|
||||||
border-color 120ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover,
|
|
||||||
.card:focus-within {
|
|
||||||
background-color: transparent;
|
|
||||||
border-color: rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.62));
|
|
||||||
}
|
|
||||||
|
|
||||||
.bookmarkButton:focus-visible {
|
|
||||||
outline: 2px solid var(--mantine-primary-color-filled);
|
|
||||||
outline-offset: 2px;
|
|
||||||
border-radius: var(--mantine-radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover > div > div.bookmarkIcon {
|
|
||||||
background-color: var(--mantine-color-iconColor-filled-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bookmarkButton:focus-visible .bookmarkIcon {
|
|
||||||
background-color: var(--mantine-color-iconColor-filled-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.card {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.compactScrollViewport > div {
|
|
||||||
display: flex !important;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.compactScrollContent {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 100%;
|
|
||||||
min-height: 100%;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
"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 { getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url";
|
||||||
|
import { getUsableWidgetQueryData } from "../common/query-state";
|
||||||
|
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 apps =
|
||||||
|
getUsableWidgetQueryData(
|
||||||
|
clientApi.app.byIds.useQuery(appIds, {
|
||||||
|
select(selectedApps) {
|
||||||
|
return selectedApps;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
) ?? [];
|
||||||
|
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,
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
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 size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
|
{t("empty")}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
) : (
|
||||||
|
<ScrollArea
|
||||||
|
type="hover"
|
||||||
|
scrollbars={plan.horizontalScroll ? "x" : "y"}
|
||||||
|
scrollbarSize={2}
|
||||||
|
scrollHideDelay={500}
|
||||||
|
offsetScrollbars
|
||||||
|
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 size={14} />
|
||||||
|
</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 size={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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,532 +1,3 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { CSSProperties } from "react";
|
export { default } from "./bookmarks-widget";
|
||||||
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";
|
|
||||||
|
|
||||||
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 default function BookmarksWidget({
|
|
||||||
options,
|
|
||||||
itemId,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
displayMode,
|
|
||||||
}: WidgetComponentProps<"bookmarks">) {
|
|
||||||
const board = useRequiredBoard();
|
|
||||||
const { ref: compactContentRef, width: compactContentWidth } = useElementSize<HTMLDivElement>();
|
|
||||||
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 (
|
|
||||||
<Stack h="100%" mih={0} gap="sm" p={height < 120 ? "xs" : "sm"}>
|
|
||||||
{options.title.length > 0 && (
|
|
||||||
<Title order={4} px="0.25rem" lineClamp={displayMode === "advanced" ? undefined : 1}>
|
|
||||||
{options.title}
|
|
||||||
</Title>
|
|
||||||
)}
|
|
||||||
{displayMode === "advanced" && (
|
|
||||||
<AdvancedBookmarksLayout
|
|
||||||
data={data}
|
|
||||||
width={width}
|
|
||||||
openNewTab={options.openNewTab}
|
|
||||||
withBorder={options.withBorder}
|
|
||||||
hasIconColor={board.iconColor !== null}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{displayMode !== "advanced" && (
|
|
||||||
<ScrollArea
|
|
||||||
classNames={{ viewport: classes.compactScrollViewport }}
|
|
||||||
type="auto"
|
|
||||||
scrollbarSize={6}
|
|
||||||
style={{ flex: 1, minHeight: 0 }}
|
|
||||||
>
|
|
||||||
<div ref={compactContentRef} className={classes.compactScrollContent}>
|
|
||||||
{options.layout === "grid" || options.layout === "gridHorizontal" ? (
|
|
||||||
<GridLayout
|
|
||||||
data={data}
|
|
||||||
columns={compactLayout.columns}
|
|
||||||
minimumItemHeight={compactLayout.minimumItemSize}
|
|
||||||
itemDirection={options.layout === "gridHorizontal" ? "horizontal" : "vertical"}
|
|
||||||
hideTitle={compactHideTitle}
|
|
||||||
hideIcon={options.hideIcon}
|
|
||||||
hideHostname={compactHideHostname}
|
|
||||||
openNewTab={options.openNewTab}
|
|
||||||
withBorder={options.withBorder}
|
|
||||||
hasIconColor={board.iconColor !== null}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<FlexLayout
|
|
||||||
data={data}
|
|
||||||
direction={options.layout}
|
|
||||||
minimumItemSize={compactLayout.minimumItemSize}
|
|
||||||
hideTitle={compactHideTitle}
|
|
||||||
hideIcon={options.hideIcon}
|
|
||||||
hideHostname={compactHideHostname}
|
|
||||||
openNewTab={options.openNewTab}
|
|
||||||
withBorder={options.withBorder}
|
|
||||||
hasIconColor={board.iconColor !== null}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<Flex direction={direction} gap={4} w="100%" mih="100%" wrap="nowrap">
|
|
||||||
{data.map((app) => {
|
|
||||||
const href = getSafeAppHref(app.href);
|
|
||||||
return (
|
|
||||||
<div key={app.id} style={{ display: "flex", flex: `1 0 ${minimumItemSize}px`, flexDirection: direction }}>
|
|
||||||
<UnstyledButton
|
|
||||||
className={classes.bookmarkButton}
|
|
||||||
component={href ? "a" : "div"}
|
|
||||||
href={href}
|
|
||||||
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
|
|
||||||
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
|
|
||||||
key={app.id}
|
|
||||||
w="100%"
|
|
||||||
>
|
|
||||||
<Card
|
|
||||||
radius={board.itemRadius}
|
|
||||||
className={classes.card}
|
|
||||||
bg="transparent"
|
|
||||||
w="100%"
|
|
||||||
display="flex"
|
|
||||||
p={4}
|
|
||||||
h="100%"
|
|
||||||
withBorder={withBorder}
|
|
||||||
>
|
|
||||||
{direction === "row" ? (
|
|
||||||
<VerticalItem
|
|
||||||
app={app}
|
|
||||||
hideTitle={hideTitle}
|
|
||||||
hideIcon={hideIcon}
|
|
||||||
hideHostname={hideHostname}
|
|
||||||
hasIconColor={hasIconColor}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<HorizontalItem
|
|
||||||
app={app}
|
|
||||||
hideTitle={hideTitle}
|
|
||||||
hideIcon={hideIcon}
|
|
||||||
hideHostname={hideHostname}
|
|
||||||
hasIconColor={hasIconColor}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</UnstyledButton>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<ScrollArea h="100%" style={{ flex: 1 }}>
|
|
||||||
<SimpleGrid cols={getAdvancedBookmarkColumns(width, data.length)} spacing="sm">
|
|
||||||
{data.map((app) => {
|
|
||||||
const href = getSafeAppHref(app.href);
|
|
||||||
return (
|
|
||||||
<UnstyledButton
|
|
||||||
key={app.id}
|
|
||||||
className={classes.bookmarkButton}
|
|
||||||
component={href ? "a" : "div"}
|
|
||||||
href={href}
|
|
||||||
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
|
|
||||||
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
|
|
||||||
>
|
|
||||||
<Card
|
|
||||||
radius={board.itemRadius}
|
|
||||||
className={classes.card}
|
|
||||||
bg="transparent"
|
|
||||||
withBorder={withBorder}
|
|
||||||
p="md"
|
|
||||||
h="100%"
|
|
||||||
>
|
|
||||||
<Group align="flex-start" wrap="nowrap">
|
|
||||||
<BookmarkIcon
|
|
||||||
app={app}
|
|
||||||
hasColor={hasIconColor}
|
|
||||||
className={classes.bookmarkIcon}
|
|
||||||
style={{ width: 40, height: 40, flex: "0 0 auto" }}
|
|
||||||
/>
|
|
||||||
<Stack gap={3} miw={0}>
|
|
||||||
<Text fw={700} size="sm">
|
|
||||||
{app.name}
|
|
||||||
</Text>
|
|
||||||
<Anchor component="span" size="xs" truncate="end">
|
|
||||||
{getBookmarkHostname(app.href)}
|
|
||||||
</Anchor>
|
|
||||||
{app.description && (
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{app.description}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
</UnstyledButton>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SimpleGrid>
|
|
||||||
</ScrollArea>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<Flex gap={COMPACT_GRID_GAP} wrap="wrap" miw="100%" mih="100%" style={{ alignContent: "stretch" }}>
|
|
||||||
{data.map((app) => {
|
|
||||||
const href = getSafeAppHref(app.href);
|
|
||||||
return (
|
|
||||||
<UnstyledButton
|
|
||||||
className={classes.bookmarkButton}
|
|
||||||
component={href ? "a" : "div"}
|
|
||||||
href={href}
|
|
||||||
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
|
|
||||||
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
|
|
||||||
key={app.id}
|
|
||||||
style={{
|
|
||||||
flex: `1 0 calc((100% - calc(${COMPACT_GRID_GAP * (columns - 1)}px * var(--mantine-scale, 1))) / ${columns})`,
|
|
||||||
minWidth: 0,
|
|
||||||
minHeight: `calc(${minimumItemHeight}px * var(--mantine-scale, 1))`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Card
|
|
||||||
h="100%"
|
|
||||||
className={classes.card}
|
|
||||||
bg="transparent"
|
|
||||||
radius={board.itemRadius}
|
|
||||||
withBorder={withBorder}
|
|
||||||
p="xs"
|
|
||||||
>
|
|
||||||
{itemDirection === "horizontal" ? (
|
|
||||||
<HorizontalItem
|
|
||||||
app={app}
|
|
||||||
hideTitle={hideTitle}
|
|
||||||
hideIcon={hideIcon}
|
|
||||||
hideHostname={hideHostname}
|
|
||||||
hasIconColor={hasIconColor}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<VerticalItem
|
|
||||||
app={app}
|
|
||||||
hideTitle={hideTitle}
|
|
||||||
hideIcon={hideIcon}
|
|
||||||
hideHostname={hideHostname}
|
|
||||||
hasIconColor={hasIconColor}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</UnstyledButton>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const VerticalItem = ({
|
|
||||||
app,
|
|
||||||
hideTitle,
|
|
||||||
hideIcon,
|
|
||||||
hideHostname,
|
|
||||||
hasIconColor,
|
|
||||||
}: {
|
|
||||||
app: BookmarkDisplayItem;
|
|
||||||
hideTitle: boolean;
|
|
||||||
hideIcon: boolean;
|
|
||||||
hideHostname: boolean;
|
|
||||||
hasIconColor: boolean;
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<Stack h="100%" miw={16} gap="sm" justify={"center"}>
|
|
||||||
{!hideTitle && (
|
|
||||||
<Text fw={700} ta="center" size="xs" lineClamp={2}>
|
|
||||||
{app.name}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{!hideIcon && (
|
|
||||||
<BookmarkIcon
|
|
||||||
app={app}
|
|
||||||
hasColor={hasIconColor}
|
|
||||||
className={classes.bookmarkIcon}
|
|
||||||
style={{
|
|
||||||
width: hideHostname && hideTitle ? "min(max(100%, 16px), 40px)" : 40,
|
|
||||||
height: hideHostname && hideTitle ? "min(max(100%, 16px), 40px)" : 40,
|
|
||||||
overflow: "auto",
|
|
||||||
flex: "unset",
|
|
||||||
marginLeft: "auto",
|
|
||||||
marginRight: "auto",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!hideHostname && (
|
|
||||||
<Anchor ta="center" component="span" size="xs" truncate="end" w="100%">
|
|
||||||
{getBookmarkHostname(app.href)}
|
|
||||||
</Anchor>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const HorizontalItem = ({
|
|
||||||
app,
|
|
||||||
hideTitle,
|
|
||||||
hideIcon,
|
|
||||||
hideHostname,
|
|
||||||
hasIconColor,
|
|
||||||
}: {
|
|
||||||
app: BookmarkDisplayItem;
|
|
||||||
hideTitle: boolean;
|
|
||||||
hideIcon: boolean;
|
|
||||||
hideHostname: boolean;
|
|
||||||
hasIconColor: boolean;
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<Group wrap="nowrap" gap="xs" h="100%" justify="start">
|
|
||||||
{!hideIcon && (
|
|
||||||
<BookmarkIcon
|
|
||||||
app={app}
|
|
||||||
hasColor={hasIconColor}
|
|
||||||
className={classes.bookmarkIcon}
|
|
||||||
style={{
|
|
||||||
overflow: "auto",
|
|
||||||
width: hideHostname ? 16 : 24,
|
|
||||||
height: hideHostname ? 16 : 24,
|
|
||||||
flex: "unset",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!(hideTitle && hideHostname) && (
|
|
||||||
<>
|
|
||||||
<Stack justify="space-between" gap={0} miw={0} style={{ flex: 1 }}>
|
|
||||||
{!hideTitle && (
|
|
||||||
<Text fw={700} size="xs" lineClamp={hideHostname ? 2 : 1}>
|
|
||||||
{app.name}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!hideHostname && (
|
|
||||||
<Anchor component="span" size="xs" truncate="end">
|
|
||||||
{getBookmarkHostname(app.href)}
|
|
||||||
</Anchor>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface BookmarkIconProps {
|
|
||||||
app: BookmarkDisplayItem;
|
|
||||||
hasColor: boolean;
|
|
||||||
className?: string;
|
|
||||||
style?: CSSProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BookmarkIcon = ({ app, hasColor, className, style }: BookmarkIconProps) =>
|
|
||||||
app.iconUrl ? (
|
|
||||||
<MaskedOrNormalImage
|
|
||||||
imageUrl={app.iconUrl}
|
|
||||||
hasColor={hasColor}
|
|
||||||
alt={app.name}
|
|
||||||
className={className}
|
|
||||||
style={style}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<IconLink aria-label={app.name} className={className} style={style} />
|
|
||||||
);
|
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
import { ActionIcon, Avatar, Group, Stack, Text } from "@mantine/core";
|
import { ActionIcon, Avatar, Group, Stack, Text } from "@mantine/core";
|
||||||
import { IconBookmark, IconX } from "@tabler/icons-react";
|
import { IconBookmark, IconLink, IconX } from "@tabler/icons-react";
|
||||||
import { z } from "zod/v4";
|
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
|
||||||
import { getSafeAppHref } from "../common/application-url";
|
|
||||||
import { createWidgetDefinition } from "../definition";
|
import { createWidgetDefinition } from "../definition";
|
||||||
import { optionsBuilder } from "../options";
|
import { optionsBuilder } from "../options";
|
||||||
import { BookmarkAddButton } from "./add-button";
|
import { BookmarkAddButton } from "./add-button";
|
||||||
|
import type { BookmarkSelectionItem } from "./add-button";
|
||||||
|
import { createDirectBookmark, getBookmarkFaviconUrl, getDirectBookmarkUrl } from "./bookmark-item";
|
||||||
|
|
||||||
export const { definition, componentLoader } = createWidgetDefinition("bookmarks", {
|
export const { definition, componentLoader } = createWidgetDefinition("bookmarks", {
|
||||||
icon: IconBookmark,
|
icon: IconBookmark,
|
||||||
@@ -18,42 +17,63 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks
|
|||||||
return optionsBuilder.from((factory) => ({
|
return optionsBuilder.from((factory) => ({
|
||||||
title: factory.text(),
|
title: factory.text(),
|
||||||
layout: factory.select({
|
layout: factory.select({
|
||||||
options: (["grid", "gridHorizontal", "row", "column"] as const).map((value) => ({
|
options: (["adaptive", "column", "row", "grid", "gridHorizontal", "icons"] as const).map((value) => ({
|
||||||
value,
|
value,
|
||||||
label: (t) => {
|
label: (t) => {
|
||||||
|
if (value === "adaptive") return t("widget.bookmarks.option.layout.option.adaptive.label");
|
||||||
if (value === "grid") return t("widget.common.layout.option.grid");
|
if (value === "grid") return t("widget.common.layout.option.grid");
|
||||||
if (value === "row") return t("widget.common.layout.option.horizontal");
|
if (value === "row") return t("widget.common.layout.option.horizontal");
|
||||||
if (value === "column") return t("widget.common.layout.option.vertical");
|
if (value === "column") return t("widget.common.layout.option.vertical");
|
||||||
|
if (value === "icons") return t("widget.bookmarks.option.layout.option.icons.label");
|
||||||
return t("widget.bookmarks.option.layout.option.gridHorizontal.label");
|
return t("widget.bookmarks.option.layout.option.gridHorizontal.label");
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
defaultValue: "column",
|
defaultValue: "adaptive",
|
||||||
|
}),
|
||||||
|
variant: factory.select({
|
||||||
|
options: (["soft", "filled", "outline", "plain"] as const).map((value) => ({
|
||||||
|
value,
|
||||||
|
label: (t) => t(`widget.bookmarks.option.variant.option.${value}.label`),
|
||||||
|
})),
|
||||||
|
defaultValue: "soft",
|
||||||
|
}),
|
||||||
|
spacing: factory.select({
|
||||||
|
options: (["xs", "sm", "md", "lg", "xl"] as const).map((value) => ({
|
||||||
|
value,
|
||||||
|
label: (t) => t(`widget.bookmarks.option.spacing.option.${value}.label`),
|
||||||
|
})),
|
||||||
|
defaultValue: "xs",
|
||||||
}),
|
}),
|
||||||
hideTitle: factory.switch({ defaultValue: false }),
|
hideTitle: factory.switch({ defaultValue: false }),
|
||||||
hideIcon: factory.switch({ defaultValue: false }),
|
hideIcon: factory.switch({ defaultValue: false }),
|
||||||
hideHostname: factory.switch({ defaultValue: false }),
|
hideHostname: factory.switch({ defaultValue: false }),
|
||||||
openNewTab: factory.switch({ defaultValue: true }),
|
openNewTab: factory.switch({ defaultValue: true }),
|
||||||
withBorder: factory.switch({ defaultValue: false }),
|
withBorder: factory.switch({ defaultValue: false }),
|
||||||
customUrls: factory.multiText({
|
customUrls: factory.internal({ defaultValue: [] as string[] }),
|
||||||
validate: z.string().refine((value) => getSafeAppHref(value) !== undefined),
|
items: factory.sortableItemList<BookmarkSelectionItem, string>({
|
||||||
withDescription: true,
|
ItemComponent: ({ item, handle, removeItem, removeLabel, rootAttributes }) => {
|
||||||
}),
|
const iconUrl = item.iconUrl ?? getBookmarkFaviconUrl(item.href);
|
||||||
items: factory.sortableItemList<RouterOutputs["app"]["selectable"][number], string>({
|
|
||||||
ItemComponent: ({ item, handle, removeItem, rootAttributes }) => {
|
|
||||||
return (
|
return (
|
||||||
<Group {...rootAttributes} tabIndex={0} justify="space-between" wrap="nowrap">
|
<Group {...rootAttributes} tabIndex={0} justify="space-between" wrap="nowrap">
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap" miw={0}>
|
||||||
{handle}
|
{handle}
|
||||||
|
|
||||||
<Group>
|
<Group wrap="nowrap" miw={0}>
|
||||||
<Avatar src={item.iconUrl} alt={item.name} />
|
<Avatar src={iconUrl} name={item.name} color="gray" radius="sm">
|
||||||
<Stack gap={0}>
|
<IconLink size={20} />
|
||||||
<Text>{item.name}</Text>
|
</Avatar>
|
||||||
|
<Stack gap={0} miw={0}>
|
||||||
|
<Text fw={600} truncate>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" truncate>
|
||||||
|
{item.href}
|
||||||
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<ActionIcon variant="transparent" color="red" onClick={removeItem}>
|
<ActionIcon variant="subtle" color="red" onClick={removeItem} aria-label={removeLabel}>
|
||||||
<IconX size="var(--mantine-font-size-xl)" />
|
<IconX size="var(--mantine-font-size-xl)" />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -62,7 +82,14 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks
|
|||||||
AddButton: BookmarkAddButton,
|
AddButton: BookmarkAddButton,
|
||||||
uniqueIdentifier: (item) => item.id,
|
uniqueIdentifier: (item) => item.id,
|
||||||
useData: (initialIds) => {
|
useData: (initialIds) => {
|
||||||
const { data, error, isLoading } = clientApi.app.byIds.useQuery(initialIds);
|
const appIds = initialIds.filter((value) => !getDirectBookmarkUrl(value));
|
||||||
|
const { data: apps, error, isLoading } = clientApi.app.byIds.useQuery(appIds);
|
||||||
|
const appsById = new Map(apps?.map((app) => [app.id, app]));
|
||||||
|
const data = initialIds.flatMap((value) => {
|
||||||
|
const directUrl = getDirectBookmarkUrl(value);
|
||||||
|
if (directUrl) return createDirectBookmark(directUrl) ?? [];
|
||||||
|
return appsById.get(value) ?? [];
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
|
|||||||
@@ -1,54 +1,69 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import {
|
import { createDirectBookmark, normalizeBookmarkUrl, splitBookmarkUrls } from "./bookmark-item";
|
||||||
createCustomBookmark,
|
import { getBookmarkCardDisplay, getBookmarkDisplayPlan } from "./layout";
|
||||||
getAdvancedBookmarkColumns,
|
|
||||||
getBookmarkHostname,
|
|
||||||
getCompactBookmarkLayout,
|
|
||||||
} from "./component";
|
|
||||||
|
|
||||||
describe("bookmark display helpers", () => {
|
describe("bookmark display helpers", () => {
|
||||||
test("uses bounded advanced columns", () => {
|
test("uses explicit width breakpoints for advanced columns", () => {
|
||||||
expect(getAdvancedBookmarkColumns(900, 8)).toBe(3);
|
expect(
|
||||||
expect(getAdvancedBookmarkColumns(120, 8)).toBe(1);
|
getBookmarkDisplayPlan({ advanced: true, width: 900, height: 500, itemCount: 8, layout: "adaptive" }),
|
||||||
expect(getAdvancedBookmarkColumns(900, 0)).toBe(1);
|
).toMatchObject({
|
||||||
|
columns: 3,
|
||||||
|
orientation: "horizontal",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
getBookmarkDisplayPlan({ advanced: true, width: 120, height: 500, itemCount: 8, layout: "adaptive" }),
|
||||||
|
).toMatchObject({
|
||||||
|
columns: 1,
|
||||||
|
orientation: "horizontal",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses container geometry to keep compact cards legible", () => {
|
test("selects an adaptive plan from width and height breakpoints", () => {
|
||||||
expect(getCompactBookmarkLayout(420, 220, 4, "gridHorizontal")).toMatchObject({
|
expect(
|
||||||
|
getBookmarkDisplayPlan({ advanced: false, width: 560, height: 260, itemCount: 4, layout: "adaptive" }),
|
||||||
|
).toMatchObject({
|
||||||
columns: 2,
|
columns: 2,
|
||||||
hideHostname: false,
|
itemHeight: 128,
|
||||||
hideTitle: false,
|
orientation: "horizontal",
|
||||||
minimumItemSize: 48,
|
showHostname: true,
|
||||||
|
showTitle: true,
|
||||||
});
|
});
|
||||||
|
expect(
|
||||||
expect(getCompactBookmarkLayout(180, 80, 4, "row")).toMatchObject({
|
getBookmarkDisplayPlan({ advanced: false, width: 180, height: 80, itemCount: 4, layout: "adaptive" }),
|
||||||
columns: 4,
|
).toMatchObject({
|
||||||
hideHostname: true,
|
columns: 1,
|
||||||
hideTitle: true,
|
orientation: "icon",
|
||||||
minimumItemSize: 96,
|
showHostname: false,
|
||||||
|
showTitle: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not crash on malformed bookmark URLs", () => {
|
test("keeps compact cards visible when icons are hidden", () => {
|
||||||
expect(getBookmarkHostname("https://homarr.dev/docs")).toBe("homarr.dev");
|
const plan = getBookmarkDisplayPlan({ advanced: false, width: 180, height: 80, itemCount: 4, layout: "icons" });
|
||||||
expect(getBookmarkHostname("not a URL")).toBeUndefined();
|
expect(
|
||||||
expect(getBookmarkHostname(null)).toBeUndefined();
|
getBookmarkCardDisplay({ advanced: false, hideHostname: false, hideIcon: true, hideTitle: false, plan }),
|
||||||
|
).toEqual({
|
||||||
|
orientation: "horizontal",
|
||||||
|
showHostname: false,
|
||||||
|
showIcon: false,
|
||||||
|
showTitle: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("creates standalone bookmarks without app records", () => {
|
test("creates standalone bookmarks without app records", () => {
|
||||||
expect(createCustomBookmark("https://homarr.dev/docs")).toEqual({
|
expect(createDirectBookmark("https://homarr.dev/docs")).toEqual({
|
||||||
id: "custom-link:https://homarr.dev/docs",
|
id: "url:https://homarr.dev/docs",
|
||||||
name: "homarr.dev",
|
name: "homarr.dev",
|
||||||
description: null,
|
description: null,
|
||||||
href: "https://homarr.dev/docs",
|
href: "https://homarr.dev/docs",
|
||||||
});
|
});
|
||||||
expect(createCustomBookmark(" https://homarr.dev/docs ")).toEqual({
|
expect(normalizeBookmarkUrl("homarr.dev/docs")).toBe("https://homarr.dev/docs");
|
||||||
id: "custom-link:https://homarr.dev/docs",
|
expect(normalizeBookmarkUrl("javascript:alert(1)")).toBeUndefined();
|
||||||
name: "homarr.dev",
|
});
|
||||||
description: null,
|
|
||||||
href: "https://homarr.dev/docs",
|
test("splits URL lists without corrupting commas inside a URL", () => {
|
||||||
});
|
expect(splitBookmarkUrls("https://one.example, two.example")).toEqual(["https://one.example", "two.example"]);
|
||||||
expect(createCustomBookmark("javascript:alert(1)")).toBeNull();
|
expect(splitBookmarkUrls("https://maps.example/@52.5,13.4,15z")).toEqual(["https://maps.example/@52.5,13.4,15z"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import type { WidgetComponentProps } from "../definition";
|
||||||
|
|
||||||
|
export type BookmarkLayout = WidgetComponentProps<"bookmarks">["options"]["layout"];
|
||||||
|
export type BookmarkOrientation = "horizontal" | "vertical" | "icon";
|
||||||
|
|
||||||
|
export interface BookmarkDisplayPlan {
|
||||||
|
columns: number;
|
||||||
|
horizontalScroll: boolean;
|
||||||
|
itemHeight: number;
|
||||||
|
itemWidth: number;
|
||||||
|
orientation: BookmarkOrientation;
|
||||||
|
showHostname: boolean;
|
||||||
|
showTitle: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookmarkCardDisplay {
|
||||||
|
orientation: BookmarkOrientation;
|
||||||
|
showHostname: boolean;
|
||||||
|
showIcon: boolean;
|
||||||
|
showTitle: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getBookmarkCardDisplay = ({
|
||||||
|
advanced,
|
||||||
|
hideHostname,
|
||||||
|
hideIcon,
|
||||||
|
hideTitle,
|
||||||
|
plan,
|
||||||
|
}: {
|
||||||
|
advanced: boolean;
|
||||||
|
hideHostname: boolean;
|
||||||
|
hideIcon: boolean;
|
||||||
|
hideTitle: boolean;
|
||||||
|
plan: BookmarkDisplayPlan;
|
||||||
|
}): BookmarkCardDisplay => {
|
||||||
|
let orientation = plan.orientation;
|
||||||
|
let showTitle = advanced || (!hideTitle && plan.showTitle);
|
||||||
|
if (!advanced && hideIcon && !hideTitle && !showTitle) {
|
||||||
|
orientation = "horizontal";
|
||||||
|
showTitle = true;
|
||||||
|
}
|
||||||
|
const showHostname = advanced || (!hideHostname && plan.showHostname);
|
||||||
|
const showIcon = advanced || !hideIcon || (!showTitle && !showHostname);
|
||||||
|
|
||||||
|
return { orientation, showHostname, showIcon, showTitle };
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BookmarkWidthBreakpoint {
|
||||||
|
adaptiveColumns: number;
|
||||||
|
adaptiveOrientation: BookmarkOrientation;
|
||||||
|
advancedColumns: number;
|
||||||
|
compactColumns: number;
|
||||||
|
gridColumns: number;
|
||||||
|
iconColumns: number;
|
||||||
|
minWidth: number;
|
||||||
|
rowItemWidth: number;
|
||||||
|
showHostname: boolean;
|
||||||
|
showTitle: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const widthBreakpoints = [
|
||||||
|
{
|
||||||
|
minWidth: 900,
|
||||||
|
adaptiveColumns: 4,
|
||||||
|
advancedColumns: 3,
|
||||||
|
compactColumns: 4,
|
||||||
|
gridColumns: 5,
|
||||||
|
iconColumns: 10,
|
||||||
|
rowItemWidth: 180,
|
||||||
|
adaptiveOrientation: "vertical",
|
||||||
|
showHostname: true,
|
||||||
|
showTitle: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
minWidth: 720,
|
||||||
|
adaptiveColumns: 3,
|
||||||
|
advancedColumns: 2,
|
||||||
|
compactColumns: 3,
|
||||||
|
gridColumns: 4,
|
||||||
|
iconColumns: 8,
|
||||||
|
rowItemWidth: 172,
|
||||||
|
adaptiveOrientation: "horizontal",
|
||||||
|
showHostname: true,
|
||||||
|
showTitle: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
minWidth: 480,
|
||||||
|
adaptiveColumns: 2,
|
||||||
|
advancedColumns: 2,
|
||||||
|
compactColumns: 2,
|
||||||
|
gridColumns: 3,
|
||||||
|
iconColumns: 6,
|
||||||
|
rowItemWidth: 160,
|
||||||
|
adaptiveOrientation: "horizontal",
|
||||||
|
showHostname: true,
|
||||||
|
showTitle: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
minWidth: 300,
|
||||||
|
adaptiveColumns: 2,
|
||||||
|
advancedColumns: 1,
|
||||||
|
compactColumns: 1,
|
||||||
|
gridColumns: 2,
|
||||||
|
iconColumns: 4,
|
||||||
|
rowItemWidth: 144,
|
||||||
|
adaptiveOrientation: "horizontal",
|
||||||
|
showHostname: false,
|
||||||
|
showTitle: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
minWidth: 0,
|
||||||
|
adaptiveColumns: 1,
|
||||||
|
advancedColumns: 1,
|
||||||
|
compactColumns: 1,
|
||||||
|
gridColumns: 1,
|
||||||
|
iconColumns: 2,
|
||||||
|
rowItemWidth: 112,
|
||||||
|
adaptiveOrientation: "icon",
|
||||||
|
showHostname: false,
|
||||||
|
showTitle: false,
|
||||||
|
},
|
||||||
|
] as const satisfies readonly BookmarkWidthBreakpoint[];
|
||||||
|
|
||||||
|
interface BookmarkHeightBreakpoint {
|
||||||
|
columnItemHeight: number;
|
||||||
|
itemHeight: number;
|
||||||
|
minHeight: number;
|
||||||
|
orientation: BookmarkOrientation;
|
||||||
|
rowItemHeight: number;
|
||||||
|
showHostname: boolean;
|
||||||
|
showTitle: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const heightBreakpoints = [
|
||||||
|
{
|
||||||
|
minHeight: 300,
|
||||||
|
columnItemHeight: 72,
|
||||||
|
itemHeight: 128,
|
||||||
|
rowItemHeight: 128,
|
||||||
|
orientation: "vertical",
|
||||||
|
showHostname: true,
|
||||||
|
showTitle: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
minHeight: 180,
|
||||||
|
columnItemHeight: 64,
|
||||||
|
itemHeight: 128,
|
||||||
|
rowItemHeight: 112,
|
||||||
|
orientation: "horizontal",
|
||||||
|
showHostname: true,
|
||||||
|
showTitle: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
minHeight: 110,
|
||||||
|
columnItemHeight: 56,
|
||||||
|
itemHeight: 80,
|
||||||
|
rowItemHeight: 80,
|
||||||
|
orientation: "horizontal",
|
||||||
|
showHostname: false,
|
||||||
|
showTitle: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
minHeight: 0,
|
||||||
|
columnItemHeight: 48,
|
||||||
|
itemHeight: 56,
|
||||||
|
rowItemHeight: 56,
|
||||||
|
orientation: "icon",
|
||||||
|
showHostname: false,
|
||||||
|
showTitle: false,
|
||||||
|
},
|
||||||
|
] as const satisfies readonly BookmarkHeightBreakpoint[];
|
||||||
|
|
||||||
|
export const getBookmarkDisplayPlan = ({
|
||||||
|
advanced,
|
||||||
|
height,
|
||||||
|
itemCount,
|
||||||
|
layout,
|
||||||
|
width,
|
||||||
|
}: {
|
||||||
|
advanced: boolean;
|
||||||
|
height: number;
|
||||||
|
itemCount: number;
|
||||||
|
layout: BookmarkLayout;
|
||||||
|
width: number;
|
||||||
|
}): BookmarkDisplayPlan => {
|
||||||
|
const count = Math.max(1, itemCount);
|
||||||
|
const normalizedWidth = Math.max(0, width);
|
||||||
|
const normalizedHeight = Math.max(0, height);
|
||||||
|
const widthSettings = widthBreakpoints.find(({ minWidth }) => normalizedWidth >= minWidth);
|
||||||
|
const heightSettings = heightBreakpoints.find(({ minHeight }) => normalizedHeight >= minHeight);
|
||||||
|
if (!widthSettings || !heightSettings) throw new Error("Bookmark breakpoints must cover all widget sizes");
|
||||||
|
|
||||||
|
if (advanced) {
|
||||||
|
return {
|
||||||
|
columns: Math.min(count, widthSettings.advancedColumns),
|
||||||
|
horizontalScroll: false,
|
||||||
|
itemHeight: 104,
|
||||||
|
itemWidth: 260,
|
||||||
|
orientation: "horizontal",
|
||||||
|
showHostname: true,
|
||||||
|
showTitle: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layout === "row") {
|
||||||
|
let orientation: BookmarkOrientation = "vertical";
|
||||||
|
if (!heightSettings.showTitle) orientation = "icon";
|
||||||
|
return {
|
||||||
|
columns: count,
|
||||||
|
horizontalScroll: true,
|
||||||
|
itemHeight: heightSettings.rowItemHeight,
|
||||||
|
itemWidth: widthSettings.rowItemWidth,
|
||||||
|
orientation,
|
||||||
|
showHostname: widthSettings.showHostname && heightSettings.showHostname,
|
||||||
|
showTitle: widthSettings.showTitle && heightSettings.showTitle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layout === "column") {
|
||||||
|
return {
|
||||||
|
columns: 1,
|
||||||
|
horizontalScroll: false,
|
||||||
|
itemHeight: heightSettings.columnItemHeight,
|
||||||
|
itemWidth: width,
|
||||||
|
orientation: "horizontal",
|
||||||
|
showHostname: widthSettings.showHostname && heightSettings.showHostname,
|
||||||
|
showTitle: widthSettings.showTitle && heightSettings.showTitle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layout === "icons") {
|
||||||
|
return {
|
||||||
|
columns: Math.min(count, widthSettings.iconColumns),
|
||||||
|
horizontalScroll: false,
|
||||||
|
itemHeight: 56,
|
||||||
|
itemWidth: 56,
|
||||||
|
orientation: "icon",
|
||||||
|
showHostname: false,
|
||||||
|
showTitle: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let columns: number = widthSettings.compactColumns;
|
||||||
|
let orientation: BookmarkOrientation = "horizontal";
|
||||||
|
if (layout === "grid") {
|
||||||
|
columns = widthSettings.gridColumns;
|
||||||
|
orientation = "vertical";
|
||||||
|
}
|
||||||
|
if (layout === "adaptive") {
|
||||||
|
columns = widthSettings.adaptiveColumns;
|
||||||
|
orientation = widthSettings.adaptiveOrientation;
|
||||||
|
if (heightSettings.orientation === "icon") orientation = "icon";
|
||||||
|
if (heightSettings.orientation === "horizontal" && orientation === "vertical") orientation = "horizontal";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
columns: Math.min(count, columns),
|
||||||
|
horizontalScroll: false,
|
||||||
|
itemHeight: heightSettings.itemHeight,
|
||||||
|
itemWidth: widthSettings.rowItemWidth,
|
||||||
|
orientation,
|
||||||
|
showHostname: orientation !== "icon" && widthSettings.showHostname && heightSettings.showHostname,
|
||||||
|
showTitle: orientation !== "icon" && widthSettings.showTitle && heightSettings.showTitle,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -4,11 +4,12 @@ import { apps } from "@homarr/db/schema";
|
|||||||
|
|
||||||
import type { Prefetch } from "../definition";
|
import type { Prefetch } from "../definition";
|
||||||
import { createTrpcQueryKey } from "../trpc-query-key";
|
import { createTrpcQueryKey } from "../trpc-query-key";
|
||||||
|
import { getDirectBookmarkUrl } from "./bookmark-item";
|
||||||
|
|
||||||
const logger = createLogger({ module: "bookmarksWidgetPrefetch" });
|
const logger = createLogger({ module: "bookmarksWidgetPrefetch" });
|
||||||
|
|
||||||
const prefetchAllAsync: Prefetch<"bookmarks"> = async (queryClient, items) => {
|
const prefetchAllAsync: Prefetch<"bookmarks"> = async (queryClient, items) => {
|
||||||
const appIds = items.flatMap((item) => item.options.items);
|
const appIds = items.flatMap((item) => item.options.items.filter((value) => !getDirectBookmarkUrl(value)));
|
||||||
const distinctAppIds = [...new Set(appIds)];
|
const distinctAppIds = [...new Set(appIds)];
|
||||||
|
|
||||||
const dbApps = await db.query.apps.findMany({
|
const dbApps = await db.query.apps.findMany({
|
||||||
@@ -16,13 +17,14 @@ const prefetchAllAsync: Prefetch<"bookmarks"> = async (queryClient, items) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.options.items.length === 0) {
|
const itemAppIds = item.options.items.filter((value) => !getDirectBookmarkUrl(value));
|
||||||
|
if (itemAppIds.length === 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
createTrpcQueryKey("app.byIds", item.options.items),
|
createTrpcQueryKey("app.byIds", itemAppIds),
|
||||||
dbApps.filter((app) => item.options.items.includes(app.id)),
|
dbApps.filter((app) => itemAppIds.includes(app.id)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,12 +34,15 @@ export interface SortableItemListInput<TItem, TOptionValue extends UniqueIdentif
|
|||||||
> {
|
> {
|
||||||
AddButton: (props: {
|
AddButton: (props: {
|
||||||
addItem: (item: TItem) => void;
|
addItem: (item: TItem) => void;
|
||||||
|
migrateItems: (items: TItem[], optionsPatch: Record<string, unknown>) => void;
|
||||||
removeItem: (value: TOptionValue) => void;
|
removeItem: (value: TOptionValue) => void;
|
||||||
values: TOptionValue[];
|
values: TOptionValue[];
|
||||||
|
initialOptions: Record<string, unknown>;
|
||||||
}) => React.ReactNode;
|
}) => React.ReactNode;
|
||||||
ItemComponent: (props: {
|
ItemComponent: (props: {
|
||||||
item: TItem;
|
item: TItem;
|
||||||
removeItem: () => void;
|
removeItem: () => void;
|
||||||
|
removeLabel: string;
|
||||||
rootAttributes: DraggableAttributes;
|
rootAttributes: DraggableAttributes;
|
||||||
handle: React.ReactNode;
|
handle: React.ReactNode;
|
||||||
}) => React.ReactNode;
|
}) => React.ReactNode;
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ export const { definition, componentLoader } = createWidgetDefinition("weather",
|
|||||||
isFormatFahrenheit: factory.switch(),
|
isFormatFahrenheit: factory.switch(),
|
||||||
disableTemperatureDecimals: factory.switch(),
|
disableTemperatureDecimals: factory.switch(),
|
||||||
animateIcons: factory.switch(),
|
animateIcons: factory.switch(),
|
||||||
showHumidity: factory.switch({ defaultValue: false, withDescription: true }),
|
showHumidity: factory.switch({ defaultValue: false }),
|
||||||
showCurrentWindSpeed: factory.switch({ withDescription: true }),
|
showCurrentWindSpeed: factory.switch(),
|
||||||
useImperialSpeed: factory.switch(),
|
useImperialSpeed: factory.switch(),
|
||||||
location: factory.location({
|
location: factory.location({
|
||||||
defaultValue: {
|
defaultValue: {
|
||||||
|
|||||||
Reference in new issue
Block a user