"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { 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 { clientApi } from "@homarr/api/client"; import { useI18n } from "@homarr/translation/client"; import type { SortableItemListInput } from "../options"; import { createDirectBookmark, getBookmarkFaviconUrl, splitBookmarkUrls } from "./bookmark-item"; import type { BookmarkItem } from "./bookmark-item"; type SelectableApp = RouterOutputs["app"]["selectable"][number]; export type BookmarkSelectionItem = SelectableApp | BookmarkItem; const createOptionValue = "$create-url"; export const BookmarkAddButton: SortableItemListInput["AddButton"] = ({ addItem, migrateItems, values, initialOptions, }) => { const t = useI18n("widget.bookmarks.option.items.apps"); const tCommon = useI18n("common"); const tSelect = useI18n("app.action.select"); const { data: apps = [], isPending, error } = clientApi.app.selectable.useQuery(); 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]); useEffect(() => { if (migrationStartedRef.current || legacyUrls.length === 0) return; migrationStartedRef.current = true; const legacyItems = legacyUrls.flatMap((url) => createDirectBookmark(url) ?? []); migrateItems(legacyItems, { customUrls: [] }); }, [legacyUrls, migrateItems]); 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) => ( {app.name} {app.href} )); return ( combobox.openDropdown()} leftSection={ } rightSection={isPending ? : undefined} styles={{ input: { minHeight: 44 } }} > 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); }} /> {pendingUrl && !selectedValues.has(pendingUrl.id) ? ( {t("addUrl")} {pendingUrl.href} ) : null} {options} {!pendingUrl && options.length === 0 ? {tSelect("notFound")} : null} {t("hint")} ); };