Merge remote-tracking branch 'origin/release/v2' into feat/widget-integration-dx

# Conflicts:
#	packages/api/src/router/board.ts
This commit is contained in:
ajnart committed 2026-08-25 10:09:03 +02:00
commit c81088d876
61 files changed
+1576 -874

No files matched your search

@@ -856,6 +856,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
: t("busy.description"),
unavailable: !canSend,
alwaysVisible: true,
placement: "fallback" as const,
interaction: (query: string) =>
createAssistantPromptInteraction({
sendPrompt,
@@ -19,6 +19,7 @@ export const useRegisterAssistantSpotlightPlaceholder = (description: string) =>
description,
unavailable: true,
alwaysVisible: true,
placement: "fallback" as const,
interaction: (_query: string) => ({ type: "none" as const }),
}),
[description, t],
@@ -0,0 +1,70 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { getSafeAppHref } from "@homarr/common";
import type { ContextSpecificItem } from "@homarr/spotlight";
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { hasPendingSpotlightOpen, spotlightOpenEvent } from "@homarr/spotlight/open";
export const BoardAppsSpotlightRegistrar = () => {
const board = useRequiredBoard();
const [isSpotlightActivated, setIsSpotlightActivated] = useState(() => hasPendingSpotlightOpen());
useEffect(() => {
if (isSpotlightActivated) return;
const activateSpotlightQueries = () => setIsSpotlightActivated(true);
window.addEventListener(spotlightOpenEvent, activateSpotlightQueries);
if (hasPendingSpotlightOpen()) activateSpotlightQueries();
return () => window.removeEventListener(spotlightOpenEvent, activateSpotlightQueries);
}, [isSpotlightActivated]);
const openInNewTabByAppId = useMemo(() => {
const result = new Map<string, boolean>();
for (const item of board.items) {
if (item.kind !== "app" || typeof item.options.appId !== "string" || item.options.appId.length === 0) continue;
const existingOpenInNewTab = result.get(item.options.appId) ?? false;
result.set(item.options.appId, existingOpenInNewTab || item.options.openInNewTab === true);
}
return result;
}, [board.items]);
const appIds = useMemo(() => Array.from(openInNewTabByAppId.keys()), [openInNewTabByAppId]);
const activeAppIds = isSpotlightActivated ? appIds : [];
const appQueries = clientApi.useQueries((trpc) => activeAppIds.map((id) => trpc.app.byId({ id })));
const apps = appQueries.flatMap((query) => query.data ?? []);
const spotlightItems = useMemo<ContextSpecificItem[]>(() => {
return apps.flatMap((app) => {
const href = getSafeAppHref(app.href);
if (!href) return [];
return [
{
id: app.id,
dedupeKey: `app:${app.id}`,
name: app.name,
icon: app.iconUrl,
interaction() {
return {
type: "link" as const,
href,
// Opening a new tab wins if any widget instance requests it.
newTab: openInNewTabByAppId.get(app.id) ?? false,
};
},
},
];
});
}, [apps, openInNewTabByAppId]);
useRegisterSpotlightContextResults("board-apps", spotlightItems, [spotlightItems]);
return null;
};
@@ -26,6 +26,7 @@ import { IconDeviceMobile, IconHomeFilled, IconLayoutBoard, IconSearch } from "@
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useOptionalBoard } from "@homarr/boards/context";
import { useRegisterSpotlightContextActions } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
import { Link, UserAvatar } from "@homarr/ui";
@@ -41,6 +42,7 @@ interface BoardSwitcherProps {
export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
const t = useI18n("board.action.switcher");
const tBoard = useI18n("board");
const manageBoardsT = useI18n("management.page.board");
const currentBoard = useOptionalBoard();
const [isOpen, setIsOpen] = useState(false);
@@ -85,6 +87,20 @@ export const BoardSwitcher = ({ children }: BoardSwitcherProps) => {
}, []);
const preloadBoards = () => void utils.board.getManageOverview.prefetch({ fullPreview: true });
const spotlightAction = useMemo(
() => ({
id: "open-board-switcher",
name: tBoard("action.switch"),
icon: IconLayoutBoard,
interaction: () => ({
type: "javaScript" as const,
onSelect: openSwitcher,
}),
}),
[openSwitcher, tBoard],
);
useRegisterSpotlightContextActions("board-switcher", [spotlightAction], [spotlightAction]);
useHotkeys([[boardSwitcherHotkey, openSwitcher, { preventDefault: true }]]);
useEffect(() => {
@@ -2,6 +2,7 @@
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import type { HotkeyItem } from "@mantine/hooks";
import { useHotkeys } from "@mantine/hooks";
import { hotkeys } from "@homarr/definitions";
@@ -13,23 +14,18 @@ const Spotlight = dynamic(() => loadSpotlight().then(({ Spotlight: Component })
loading: () => null,
});
const spotlightHotkeys: HotkeyItem[] = [
[hotkeys.openSpotlight, () => openSpotlight(), { preventDefault: true }],
[hotkeys.openSpotlightApps, () => openSpotlight({ mode: "apps" }), { preventDefault: true }],
[hotkeys.openSpotlightAssistant, () => openSpotlight({ mode: "assistant" }), { preventDefault: true }],
[hotkeys.openSpotlightPreferences, () => openSpotlight({ mode: "preferences" }), { preventDefault: true }],
];
export const LazySpotlight = () => {
const [isMounted, setIsMounted] = useState(false);
const [isPreloaded, setIsPreloaded] = useState(false);
useHotkeys(
isMounted
? []
: [
[
hotkeys.openSpotlight,
() => {
setIsMounted(true);
openSpotlight();
},
],
],
);
useHotkeys(spotlightHotkeys, [], true);
useEffect(() => {
let isActive = true;
@@ -61,12 +57,8 @@ export const LazySpotlight = () => {
return isMounted || isPreloaded ? (
<>
{isPreloaded && <span hidden data-homarr-dev-benchmark-spotlight-preloaded />}
{isMounted && (
<>
<span hidden data-homarr-dev-benchmark-spotlight-mounted />
<Spotlight />
</>
)}
{isMounted && <span hidden data-homarr-dev-benchmark-spotlight-mounted />}
<Spotlight />
</>
) : null;
};
@@ -20,7 +20,7 @@ export const DesktopSearchInput = () => {
w={400}
size="sm"
leftSection={<IconSearch size={20} stroke={1.5} />}
onClick={openSpotlight}
onClick={() => openSpotlight()}
radius="xl"
>
{`${t("search.placeholder")}...`}
@@ -32,7 +32,7 @@ export const MobileSearchButton = () => {
return (
<HeaderButton
data-homarr-dev-benchmark-interaction="search"
onClick={openSpotlight}
onClick={() => openSpotlight()}
className={classes.mobileSearch}
>
<IconSearch size={20} stroke={1.5} />