diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_edit-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_edit-actions.tsx
index 87eda8813..3723ea9d3 100644
--- a/apps/nextjs/src/app/[locale]/boards/(content)/_edit-actions.tsx
+++ b/apps/nextjs/src/app/[locale]/boards/(content)/_edit-actions.tsx
@@ -1,15 +1,18 @@
"use client";
-import { useEffect, useRef } from "react";
-import { usePathname, useRouter, useSearchParams } from "next/navigation";
-import { IconPlus } from "@tabler/icons-react";
+import { Group, Menu } from "@mantine/core";
+import { IconBox, IconChevronDown, IconLayoutGridAdd, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
+import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useModalAction } from "@homarr/modals";
+import { AppSelectModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
-import { useBoardAddActions } from "~/components/board/use-board-add-actions";
-import { UniversalCreateModal } from "~/components/create/universal-create-modal";
+import { useItemActions } from "~/components/board/items/item-actions";
+import { ItemSelectModal } from "~/components/board/items/item-select-modal";
+import { useContainerActions } from "~/components/board/sections/container/container-actions";
+import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { HeaderButton } from "~/components/layout/header/button";
export default function BoardEditActions() {
@@ -17,41 +20,62 @@ export default function BoardEditActions() {
}
const AddMenu = () => {
+ const { data: session } = useSession();
const board = useRequiredBoard();
- const { addWidget, addApp, connectService, addContainer, canConnectService } = useBoardAddActions();
- const { openModal } = useModalAction(UniversalCreateModal);
+ const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
+ const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
+ const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
+ const { addContainer } = useContainerActions();
+ const { createItem } = useItemActions();
const t = useI18n();
- const pathname = usePathname();
- const router = useRouter();
- const searchParams = useSearchParams();
- const handledInitialAdd = useRef(false);
- useEffect(() => {
- if (handledInitialAdd.current || searchParams.get("add") !== "true") return;
- handledInitialAdd.current = true;
- addWidget();
+ const handleSelectItem = () => {
+ openItemSelectModal({ boardId: board.id });
+ };
- const nextSearchParams = new URLSearchParams(searchParams.toString());
- nextSearchParams.delete("add");
- const query = nextSearchParams.toString();
- router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
- }, [addWidget, board.id, pathname, router, searchParams]);
+ const handleSelectApp = () => {
+ openAppSelectModal({
+ onSelect: (app) => {
+ createItem({
+ kind: "app",
+ options: { appId: app.id },
+ });
+ },
+ withCreate: session?.user.permissions.includes("app-create") ?? false,
+ });
+ };
+
+ const handleAddIntegration = () => {
+ openIntegrationSelectModal({});
+ };
return (
-
- openModal({
- boardActions: {
- widget: addWidget,
- app: addApp,
- integration: canConnectService ? connectService : undefined,
- container: addContainer,
- },
- })
- }
- >
-
-
+
);
};
diff --git a/apps/nextjs/src/components/create/setup-analytics.ts b/apps/nextjs/src/components/create/setup-analytics.ts
index 5a7e12815..caacb89d7 100644
--- a/apps/nextjs/src/components/create/setup-analytics.ts
+++ b/apps/nextjs/src/components/create/setup-analytics.ts
@@ -37,7 +37,7 @@ export const useSetupAnalytics = () => {
onError() {
showErrorNotification({
title: t("common.error"),
- message: t("universalCreate.analyticsError"),
+ message: t("common.error"),
});
},
});
diff --git a/apps/nextjs/src/components/create/universal-create-actions.spec.ts b/apps/nextjs/src/components/create/universal-create-actions.spec.ts
deleted file mode 100644
index 60e323a4b..000000000
--- a/apps/nextjs/src/components/create/universal-create-actions.spec.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import { describe, expect, test } from "vitest";
-
-import type { RankedUniversalCreateAction } from "./universal-create-actions";
-import { filterAndRankUniversalCreateActions, getUniversalCreateActionDefinitions } from "./universal-create-actions";
-
-describe("getUniversalCreateActionDefinitions", () => {
- test("only exposes actions allowed without a board context", () => {
- const actions = getUniversalCreateActionDefinitions({
- hasBoardContext: false,
- permissions: ["board-create", "integration-create"],
- });
-
- expect(actions.map(({ key }) => key)).toEqual(["integration", "board"]);
- });
-
- test("places globally-created apps in the library group", () => {
- const actions = getUniversalCreateActionDefinitions({ hasBoardContext: false, permissions: ["app-create"] });
-
- expect(actions).toEqual([expect.objectContaining({ key: "app", group: "library" })]);
- });
-
- test("keeps board content actions available without global create permissions", () => {
- const actions = getUniversalCreateActionDefinitions({ hasBoardContext: true, permissions: [] });
-
- expect(actions.map(({ key }) => key)).toEqual(["widget", "app", "container"]);
- });
-
- test("exposes the complete surface to administrators on a board", () => {
- const actions = getUniversalCreateActionDefinitions({
- hasBoardContext: true,
- permissions: ["admin", "app-create", "integration-create", "board-create"],
- });
-
- expect(actions.map(({ key }) => key)).toEqual([
- "widget",
- "app",
- "integration",
- "container",
- "board",
- "workshop",
- "customWidget",
- ]);
- });
-});
-
-describe("filterAndRankUniversalCreateActions", () => {
- const actions = [
- { key: "widget", group: "currentBoard", priority: 100, name: "Add widget", description: "Add content" },
- {
- key: "integration",
- group: "library",
- priority: 80,
- name: "Connect service",
- description: "Create an integration",
- keywords: ["provider"],
- },
- { key: "board", group: "boards", priority: 60, name: "Create board", description: "Start a dashboard" },
- ] satisfies RankedUniversalCreateAction[];
-
- test("uses product priority when no search is entered", () => {
- expect(filterAndRankUniversalCreateActions(actions, "").map(({ key }) => key)).toEqual([
- "widget",
- "integration",
- "board",
- ]);
- });
-
- test("ranks a name prefix ahead of a description or keyword match", () => {
- const results = filterAndRankUniversalCreateActions(actions, "create");
-
- expect(results.map(({ key }) => key)).toEqual(["board", "integration"]);
- });
-
- test("matches keywords and removes unrelated actions", () => {
- expect(filterAndRankUniversalCreateActions(actions, "provider").map(({ key }) => key)).toEqual(["integration"]);
- });
-});
diff --git a/apps/nextjs/src/components/create/universal-create-actions.ts b/apps/nextjs/src/components/create/universal-create-actions.ts
deleted file mode 100644
index 7f9d37643..000000000
--- a/apps/nextjs/src/components/create/universal-create-actions.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-import type { GroupPermissionKey } from "@homarr/definitions";
-
-export const universalCreateActionKeys = [
- "widget",
- "app",
- "integration",
- "container",
- "board",
- "workshop",
- "customWidget",
-] as const;
-
-export type UniversalCreateActionKey = (typeof universalCreateActionKeys)[number];
-export type UniversalCreateActionGroup = "currentBoard" | "library" | "boards";
-
-export interface UniversalCreateContext {
- hasBoardContext: boolean;
- permissions: readonly GroupPermissionKey[];
-}
-
-interface UniversalCreateActionDefinition {
- key: UniversalCreateActionKey;
- group: UniversalCreateActionGroup;
- priority: number;
- isVisible: (context: UniversalCreateContext) => boolean;
-}
-
-const hasPermission = (context: UniversalCreateContext, permission: GroupPermissionKey) =>
- context.permissions.includes(permission);
-
-const definitions: readonly UniversalCreateActionDefinition[] = [
- {
- key: "widget",
- group: "currentBoard",
- priority: 100,
- isVisible: ({ hasBoardContext }) => hasBoardContext,
- },
- {
- key: "app",
- group: "currentBoard",
- priority: 90,
- isVisible: (context) => context.hasBoardContext || hasPermission(context, "app-create"),
- },
- {
- key: "integration",
- group: "library",
- priority: 80,
- isVisible: (context) => hasPermission(context, "integration-create"),
- },
- {
- key: "container",
- group: "currentBoard",
- priority: 70,
- isVisible: ({ hasBoardContext }) => hasBoardContext,
- },
- {
- key: "board",
- group: "boards",
- priority: 60,
- isVisible: (context) => hasPermission(context, "board-create"),
- },
- {
- key: "workshop",
- group: "library",
- priority: 40,
- isVisible: (context) => hasPermission(context, "admin"),
- },
- {
- key: "customWidget",
- group: "library",
- priority: 30,
- isVisible: (context) => hasPermission(context, "admin"),
- },
-];
-
-export interface RankedUniversalCreateAction {
- key: UniversalCreateActionKey;
- group: UniversalCreateActionGroup;
- priority: number;
- name: string;
- description: string;
- keywords?: readonly string[];
-}
-
-export const getUniversalCreateActionDefinitions = (context: UniversalCreateContext) =>
- definitions
- .filter((definition) => definition.isVisible(context))
- .map((definition) =>
- definition.key === "app" && !context.hasBoardContext ? { ...definition, group: "library" as const } : definition,
- );
-
-const normalize = (value: string) => value.trim().toLocaleLowerCase();
-
-export const filterAndRankUniversalCreateActions = (
- actions: readonly TAction[],
- query: string,
-) => {
- const normalizedQuery = normalize(query);
-
- return actions
- .map((action) => {
- const name = normalize(action.name);
- const searchableText = normalize([action.name, action.description, ...(action.keywords ?? [])].join(" "));
- const relevance =
- normalizedQuery.length === 0
- ? 0
- : name === normalizedQuery
- ? 300
- : name.startsWith(normalizedQuery)
- ? 200
- : searchableText.includes(normalizedQuery)
- ? 100
- : -1;
-
- return { action, relevance };
- })
- .filter(({ relevance }) => relevance >= 0)
- .toSorted((left, right) => right.relevance - left.relevance || right.action.priority - left.action.priority)
- .map(({ action }) => action);
-};
diff --git a/apps/nextjs/src/components/create/universal-create-modal.module.d.css.ts b/apps/nextjs/src/components/create/universal-create-modal.module.d.css.ts
deleted file mode 100644
index 5de57c8f7..000000000
--- a/apps/nextjs/src/components/create/universal-create-modal.module.d.css.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-declare const classes: {
- readonly action: string;
- readonly actionIcon: string;
-};
-export default classes;
diff --git a/apps/nextjs/src/components/create/universal-create-modal.tsx b/apps/nextjs/src/components/create/universal-create-modal.tsx
deleted file mode 100644
index ab66845e2..000000000
--- a/apps/nextjs/src/components/create/universal-create-modal.tsx
+++ /dev/null
@@ -1,246 +0,0 @@
-"use client";
-
-import { useEffect, useMemo, useRef, useState } from "react";
-import { useRouter } from "next/navigation";
-import { Badge, Center, Divider, Input, ScrollArea, SimpleGrid, Stack, Text, ThemeIcon } from "@mantine/core";
-import {
- IconApps,
- IconBox,
- IconBuildingStore,
- IconCode,
- IconLayoutDashboard,
- IconLayoutGridAdd,
- IconPlugConnected,
- IconSearch,
-} from "@tabler/icons-react";
-
-import { useSession } from "@homarr/auth/client";
-import type { GroupPermissionKey } from "@homarr/definitions";
-import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
-import { AddBoardModal } from "@homarr/modals-collection";
-import { useScopedI18n } from "@homarr/translation/client";
-import { SelectableCard } from "@homarr/ui";
-import type { TablerIcon } from "@homarr/ui";
-
-import type {
- RankedUniversalCreateAction,
- UniversalCreateActionGroup,
- UniversalCreateActionKey,
-} from "./universal-create-actions";
-import { filterAndRankUniversalCreateActions, getUniversalCreateActionDefinitions } from "./universal-create-actions";
-import type { SetupMetricEntryPoint } from "./setup-analytics";
-import { useSetupAnalytics } from "./setup-analytics";
-
-type BoardActionKey = "widget" | "app" | "integration" | "container";
-
-export interface UniversalCreateModalProps {
- boardActions?: Partial void>>;
- entryPoint?: SetupMetricEntryPoint;
-}
-
-const actionIcons = {
- widget: IconApps,
- app: IconBox,
- integration: IconPlugConnected,
- container: IconLayoutGridAdd,
- board: IconLayoutDashboard,
- workshop: IconBuildingStore,
- customWidget: IconCode,
-} satisfies Record;
-
-const groupColors: Record = {
- currentBoard: "primaryColor",
- library: "cyan",
- boards: "teal",
-};
-
-const selectGridCols = { base: 1, sm: 2, md: 3 };
-
-export const UniversalCreateModal = createModal(({ actions, innerProps }) => {
- const [search, setSearch] = useState("");
- const { data: session } = useSession();
- const router = useRouter();
- const t = useScopedI18n("universalCreate");
- const { openModal: openAddBoardModal } = useModalAction(AddBoardModal);
- const trackedOpen = useRef(false);
- const boardActions = innerProps.boardActions;
- const entryPoint = innerProps.entryPoint ?? (boardActions ? "board" : "header");
- const trackSetup = useSetupAnalytics();
- const permissions = (session?.user.permissions ?? []) as GroupPermissionKey[];
- const definitions = getUniversalCreateActionDefinitions({
- hasBoardContext: boardActions !== undefined,
- permissions,
- });
-
- useEffect(() => {
- if (trackedOpen.current) return;
- trackedOpen.current = true;
- trackSetup("surface-opened", {
- entryPoint,
- hasBoardContext: boardActions !== undefined,
- });
- }, [boardActions, entryPoint, trackSetup]);
-
- const invokeAction = (key: UniversalCreateActionKey) => {
- const boardAction = key in (boardActions ?? {}) ? boardActions?.[key as BoardActionKey] : undefined;
- actions.closeModal();
- trackSetup("intent-selected", {
- entryPoint,
- intent: key,
- outcome: "continued",
- hasBoardContext: boardActions !== undefined,
- });
-
- if (boardAction) {
- boardAction();
- return;
- }
-
- switch (key) {
- case "app":
- router.push("/manage/apps/new");
- break;
- case "integration":
- router.push("/manage/integrations/new");
- break;
- case "board":
- openAddBoardModal(undefined);
- break;
- case "workshop":
- router.push("/manage/custom-widgets/workshop");
- break;
- case "customWidget":
- router.push("/manage/custom-widgets/new");
- break;
- case "widget":
- case "container":
- break;
- }
- };
-
- const resolvedActions = useMemo(
- () =>
- definitions.map(
- (definition) =>
- ({
- ...definition,
- name: t(`action.${definition.key}.label`),
- description: t(`action.${definition.key}.description`),
- keywords: t(`action.${definition.key}.keywords`).split(" "),
- }) satisfies RankedUniversalCreateAction,
- ),
- [definitions, t],
- );
-
- const filteredActions = useMemo(
- () => filterAndRankUniversalCreateActions(resolvedActions, search),
- [resolvedActions, search],
- );
-
- const isSearching = search.trim().length > 0;
- const groups = ["currentBoard", "library", "boards"] satisfies UniversalCreateActionGroup[];
-
- return (
-
- {/* Top Search Input */}
- setSearch(event.currentTarget.value)}
- leftSection={}
- placeholder={t("search.placeholder")}
- aria-label={t("search.label")}
- data-autofocus
- onKeyDown={(event) => {
- if (event.key === "Enter" && filteredActions.length === 1 && filteredActions[0]) {
- invokeAction(filteredActions[0].key);
- }
- }}
- />
-
- {/* Action Cards Grid */}
-
-
- {isSearching ? (
-
- {filteredActions.map((action) => {
- const Icon = actionIcons[action.key];
- return (
- invokeAction(action.key)}
- aria-label={action.name}
- icon={
-
-
-
- }
- title={action.name}
- topRight={
-
- {t(`group.${action.group}`)}
-
- }
- description={action.description}
- />
- );
- })}
-
- ) : (
- groups.map((group) => {
- const groupActions = resolvedActions
- .filter((action) => action.group === group)
- .toSorted((left, right) => right.priority - left.priority);
- if (groupActions.length === 0) return null;
-
- return (
-
-
- {t(`group.${group}`)}
-
- }
- labelPosition="left"
- />
-
- {groupActions.map((action) => {
- const Icon = actionIcons[action.key];
- return (
- invokeAction(action.key)}
- aria-label={action.name}
- icon={
-
-
-
- }
- title={action.name}
- topRight={
-
- {t(`group.${action.group}`)}
-
- }
- description={action.description}
- />
- );
- })}
-
-
- );
- })
- )}
-
- {filteredActions.length === 0 && (
-
- {t("search.noResults")}
-
- )}
-
-
-
- );
-}).withOptions({
- defaultTitle: (t) => t("universalCreate.title"),
- size: modalSizeSelect,
-});
diff --git a/apps/nextjs/src/components/create/universal-create-spotlight.tsx b/apps/nextjs/src/components/create/universal-create-spotlight.tsx
deleted file mode 100644
index 35832a64e..000000000
--- a/apps/nextjs/src/components/create/universal-create-spotlight.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-"use client";
-
-import { useMemo } from "react";
-import { IconPlus } from "@tabler/icons-react";
-
-import { useModalAction } from "@homarr/modals";
-import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
-import { useScopedI18n } from "@homarr/translation/client";
-
-import { UniversalCreateModal } from "./universal-create-modal";
-
-/** Keeps Universal Create available from ordinary Spotlight search, without requiring command mode. */
-export const UniversalCreateSpotlightResult = () => {
- const t = useScopedI18n("universalCreate");
- const { openModal } = useModalAction(UniversalCreateModal);
- const item = useMemo(
- () => ({
- id: "universal-create",
- name: t("title"),
- description: t("trigger.description"),
- icon: IconPlus,
- alwaysVisible: true,
- interaction: () => ({
- type: "javaScript" as const,
- onSelect: () => {
- openModal({ entryPoint: "spotlight" });
- },
- }),
- }),
- [openModal, t],
- );
-
- useRegisterSpotlightContextResults("universal-create", [item], [item]);
- return null;
-};
diff --git a/apps/nextjs/src/components/create/universal-create-trigger.tsx b/apps/nextjs/src/components/create/universal-create-trigger.tsx
deleted file mode 100644
index 3bf2d4269..000000000
--- a/apps/nextjs/src/components/create/universal-create-trigger.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-"use client";
-
-import { useEffect, useRef } from "react";
-import { usePathname, useRouter, useSearchParams } from "next/navigation";
-import { IconPlus } from "@tabler/icons-react";
-
-import { useModalAction } from "@homarr/modals";
-import { useScopedI18n } from "@homarr/translation/client";
-
-import { HeaderButton } from "~/components/layout/header/button";
-import { UniversalCreateModal } from "./universal-create-modal";
-
-export const UniversalCreateHeaderAction = () => {
- const t = useScopedI18n("universalCreate");
- const { openModal } = useModalAction(UniversalCreateModal);
-
- return (
- openModal({ entryPoint: "header" })} aria-label={t("trigger.label")}>
-
-
- );
-};
-
-export const UniversalCreateQueryGate = () => {
- const pathname = usePathname();
- const router = useRouter();
- const searchParams = useSearchParams();
- const { openModal } = useModalAction(UniversalCreateModal);
- const handled = useRef(false);
- const shouldOpen = searchParams.get("create") === "true";
-
- useEffect(() => {
- if (!shouldOpen) {
- handled.current = false;
- return;
- }
- if (handled.current) return;
-
- handled.current = true;
- openModal({ entryPoint: "spotlight" });
-
- const nextSearchParams = new URLSearchParams(searchParams.toString());
- nextSearchParams.delete("create");
- const query = nextSearchParams.toString();
- router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
- }, [openModal, pathname, router, searchParams, shouldOpen]);
-
- return null;
-};
diff --git a/apps/nextjs/src/components/layout/header.tsx b/apps/nextjs/src/components/layout/header.tsx
index 74e52d5b8..2ad377576 100644
--- a/apps/nextjs/src/components/layout/header.tsx
+++ b/apps/nextjs/src/components/layout/header.tsx
@@ -3,8 +3,6 @@ import { AppShellHeader, Group, UnstyledButton } from "@mantine/core";
import { Link } from "@homarr/ui";
-import { UniversalCreateHeaderAction, UniversalCreateQueryGate } from "~/components/create/universal-create-trigger";
-import { UniversalCreateSpotlightResult } from "~/components/create/universal-create-spotlight";
import { ClientBurger } from "./header/burger";
import { DesktopSearchInput, MobileSearchButton } from "./header/search";
import { TourTarget } from "./header/tour-target";
@@ -38,7 +36,6 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
- {hasNavigation && }
{actions}
-
-
);
diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json
index fbfc0bb5c..3611ce4c4 100644
--- a/packages/translation/src/lang/en.json
+++ b/packages/translation/src/lang/en.json
@@ -236,61 +236,6 @@
}
}
},
- "universalCreate": {
- "title": "Create",
- "analyticsError": "Usage metrics could not be recorded. Your action was not affected.",
- "trigger": {
- "label": "Create something",
- "description": "Add content, connect a service, or create a board from one place."
- },
- "search": {
- "label": "Search create actions",
- "placeholder": "What do you want to create?",
- "noResults": "No matching actions"
- },
- "group": {
- "currentBoard": "Current board",
- "library": "Apps and services",
- "boards": "Boards"
- },
- "action": {
- "widget": {
- "label": "Add widget",
- "description": "Add live information or controls to this board.",
- "keywords": "item dashboard content"
- },
- "app": {
- "label": "Add or create app",
- "description": "Place an app shortcut on this board or create one for your library.",
- "keywords": "shortcut bookmark application"
- },
- "integration": {
- "label": "Connect service",
- "description": "Connect a service so widgets can use its data.",
- "keywords": "integration provider connection"
- },
- "container": {
- "label": "Add container",
- "description": "Group related board content into a resizable section.",
- "keywords": "section group layout"
- },
- "board": {
- "label": "Create board",
- "description": "Start a new dashboard and add its first widget.",
- "keywords": "dashboard page"
- },
- "workshop": {
- "label": "Browse Workshop",
- "description": "Discover community-built widgets and install them.",
- "keywords": "community discover install"
- },
- "customWidget": {
- "label": "Create custom widget",
- "description": "Build a widget tailored to your own workflow.",
- "keywords": "code jsx developer"
- }
- }
- },
"user": {
"title": "Users",
"name": "User",