diff --git a/apps/nextjs/src/components/board/board-layout-thumbnail.module.css b/apps/nextjs/src/components/board/board-layout-thumbnail.module.css
index 41909b4db..1b137ee7a 100644
--- a/apps/nextjs/src/components/board/board-layout-thumbnail.module.css
+++ b/apps/nextjs/src/components/board/board-layout-thumbnail.module.css
@@ -47,14 +47,29 @@
}
.tile {
+ position: relative;
min-width: 0;
min-height: 0;
+ overflow: hidden;
border: rem(1px) solid color-mix(in srgb, var(--mantine-primary-color-filled) 32%, transparent);
border-radius: max(rem(2px), calc(var(--mantine-radius-default) / 2));
background: color-mix(in srgb, var(--mantine-primary-color-filled) 18%, var(--mantine-color-body));
box-shadow: 0 rem(1px) rem(3px) rgb(0 0 0 / 8%);
}
+.itemIcon {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: rem(16px) !important;
+ height: rem(16px) !important;
+ max-width: 55%;
+ max-height: 55%;
+ object-fit: contain;
+ color: var(--mantine-color-text);
+ transform: translate(-50%, -50%);
+}
+
.container {
border-style: dashed;
border-color: color-mix(in srgb, var(--mantine-color-teal-filled) 45%, transparent);
diff --git a/apps/nextjs/src/components/board/board-layout-thumbnail.tsx b/apps/nextjs/src/components/board/board-layout-thumbnail.tsx
index 829df79b8..ee0fac463 100644
--- a/apps/nextjs/src/components/board/board-layout-thumbnail.tsx
+++ b/apps/nextjs/src/components/board/board-layout-thumbnail.tsx
@@ -1,9 +1,10 @@
-import { Box, Center } from "@mantine/core";
+import { Box, Center, Image } from "@mantine/core";
import { IconLayoutGrid } from "@tabler/icons-react";
import type { BoardPreviewData } from "@homarr/boards/layout-preview";
import { projectBoardLayout } from "@homarr/boards/layout-preview";
import { boardLanes, getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
+import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import classes from "./board-layout-thumbnail.module.css";
@@ -73,17 +74,27 @@ export const BoardLayoutThumbnail = ({ preview, label }: BoardLayoutThumbnailPro
gridTemplateRows: `repeat(${rowCount}, minmax(0, 1fr))`,
}}
>
- {laneElements.map((element) => (
-
- ))}
+ {laneElements.map((element) => {
+ const item = element.type === "item" ? preview.items.find((item) => item.id === element.id) : undefined;
+ const WidgetIcon = item?.kind ? widgetCatalogIcons[item.kind] : undefined;
+ return (
+
+ {item?.iconUrl ? (
+
+ ) : WidgetIcon ? (
+
+ ) : null}
+
+ );
+ })}
);
})}
diff --git a/e2e/onboarding.spec.ts b/e2e/onboarding.spec.ts
index d69125f50..e417b619f 100644
--- a/e2e/onboarding.spec.ts
+++ b/e2e/onboarding.spec.ts
@@ -197,7 +197,7 @@ describe("Onboarding", () => {
try {
const initialBaseUrl = `http://${initialContainer.getHost()}:${initialContainer.getMappedPort(7575)}`;
await page.goto(`${initialBaseUrl}/init`);
- await page.getByRole("button", { name: "Come from a backup" }).click();
+ await page.getByRole("button", { name: "Restore backup" }).click();
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles({
name: "missing-metadata.zip",
diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts
index 440b4eb45..2095dc608 100644
--- a/packages/api/src/router/board.ts
+++ b/packages/api/src/router/board.ts
@@ -23,6 +23,7 @@ import {
integrationItems,
integrationUserPermissions,
integrations,
+ apps,
itemLayouts,
items,
layouts,
@@ -295,6 +296,11 @@ export const boardRouter = createTRPCRouter({
width: true,
height: true,
},
+ with: {
+ item: {
+ columns: { kind: true, options: true },
+ },
+ },
where: and(
inArray(itemLayouts.layoutId, previewLayoutIds),
inArray(itemLayouts.sectionId, rootSectionIds),
@@ -333,6 +339,27 @@ export const boardRouter = createTRPCRouter({
])
: [[], []];
+ const appIdByItemId = new Map(
+ previewItemLayouts.flatMap((layout) => {
+ if (layout.item.kind !== "app") return [];
+ try {
+ const { appId } = superjson.parse<{ appId?: unknown }>(layout.item.options);
+ return typeof appId === "string" ? [[layout.itemId, appId] as const] : [];
+ } catch {
+ return [];
+ }
+ }),
+ );
+ const previewAppIds = [...new Set(appIdByItemId.values())];
+ const previewApps =
+ previewAppIds.length > 0
+ ? await ctx.db.query.apps.findMany({
+ columns: { id: true, iconUrl: true },
+ where: inArray(apps.id, previewAppIds),
+ })
+ : [];
+ const appIconUrlById = new Map(previewApps.map((app) => [app.id, app.iconUrl]));
+
return dbBoards.map(({ layouts: boardLayouts, sections: boardSections, ...board }) => {
const previewLayout = boardLayouts.at(0);
@@ -365,7 +392,15 @@ export const boardRouter = createTRPCRouter({
const itemPreview = previewLayout
? previewItemLayouts
.filter((layout) => layout.layoutId === previewLayout.id && isInsideRootLane(layout))
- .map((layout) => ({ id: layout.itemId, layouts: [layout] }))
+ .map((layout) => {
+ const appId = appIdByItemId.get(layout.itemId);
+ return {
+ id: layout.itemId,
+ kind: layout.item.kind,
+ iconUrl: appId ? appIconUrlById.get(appId) : undefined,
+ layouts: [layout],
+ };
+ })
: [];
const containerPreview = previewLayout
? previewSectionLayouts
diff --git a/packages/api/src/router/test/board.spec.ts b/packages/api/src/router/test/board.spec.ts
index 638173216..382b5f88a 100644
--- a/packages/api/src/router/test/board.spec.ts
+++ b/packages/api/src/router/test/board.spec.ts
@@ -6,6 +6,7 @@ import { createId } from "@homarr/common";
import type { Database, InferInsertModel } from "@homarr/db";
import { and, eq } from "@homarr/db";
import {
+ apps,
boardGroupPermissions,
boards,
boardUserPermissions,
@@ -457,6 +458,7 @@ describe("getManageOverview", () => {
items: [
{
id: itemId,
+ kind: "clock",
layouts: [{ layoutId: baseLayoutId, sectionId: rootSectionId, width: 4, height: 2 }],
},
],
@@ -475,6 +477,64 @@ describe("getManageOverview", () => {
expect(result[0]?.preview?.sections).toHaveLength(2);
});
+ test("includes app and widget icons in compact previews", async () => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+ const boardId = createId();
+ const layoutId = createId();
+ const rootSectionId = createId();
+ const appId = createId();
+ const appItemId = createId();
+
+ await db.insert(users).values({ id: defaultCreatorId });
+ await db.insert(boards).values({ id: boardId, name: "icon-overview", creatorId: defaultCreatorId });
+ await db.insert(layouts).values({
+ id: layoutId,
+ name: "Base",
+ boardId,
+ columnCount: 12,
+ breakpoint: 768,
+ role: "base",
+ });
+ await db.insert(sections).values({
+ id: rootSectionId,
+ boardId,
+ kind: "empty",
+ xOffset: 0,
+ yOffset: 0,
+ });
+ await db.insert(apps).values({
+ id: appId,
+ name: "Sonarr",
+ iconUrl: "https://example.com/sonarr.svg",
+ });
+ await db.insert(items).values({
+ id: appItemId,
+ boardId,
+ kind: "app",
+ options: SuperJSON.stringify({ appId }),
+ });
+ await db.insert(itemLayouts).values({
+ itemId: appItemId,
+ sectionId: rootSectionId,
+ layoutId,
+ xOffset: 0,
+ yOffset: 0,
+ width: 2,
+ height: 2,
+ });
+
+ const result = await caller.getManageOverview();
+
+ expect(result[0]?.preview?.items).toEqual([
+ expect.objectContaining({
+ id: appItemId,
+ kind: "app",
+ iconUrl: "https://example.com/sonarr.svg",
+ }),
+ ]);
+ });
+
test("uses the highest-breakpoint layout when a board has no base layout", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
diff --git a/packages/boards/src/layout-preview.ts b/packages/boards/src/layout-preview.ts
index db5747031..278edb7c4 100644
--- a/packages/boards/src/layout-preview.ts
+++ b/packages/boards/src/layout-preview.ts
@@ -1,6 +1,6 @@
import { generateResponsiveGridFor } from "@homarr/common";
import type { GridAlgorithmItem } from "@homarr/common";
-import type { BoardLane, LayoutRole } from "@homarr/definitions";
+import type { BoardLane, LayoutRole, WidgetKind } from "@homarr/definitions";
import { boardLanes, getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
export interface BoardPreviewLayout {
@@ -35,6 +35,8 @@ export interface BoardPreviewData {
sections: readonly BoardPreviewSection[];
items: readonly {
id: string;
+ kind?: WidgetKind;
+ iconUrl?: string | null;
layouts: readonly BoardPreviewItemLayout[];
}[];
}
diff --git a/packages/onboarding/src/setup-studio.tsx b/packages/onboarding/src/setup-studio.tsx
index 24249895d..13cd9e52e 100644
--- a/packages/onboarding/src/setup-studio.tsx
+++ b/packages/onboarding/src/setup-studio.tsx
@@ -231,6 +231,7 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
const [columnCount, setColumnCount] = useState(10);
const [leftSidebar, setLeftSidebar] = useState(false);
const [rightSidebar, setRightSidebar] = useState(false);
+ const [isApplying, setIsApplying] = useState(false);
const [applyProgress, setApplyProgress] = useState(0);
const [applyMessage, setApplyMessage] = useState("");
const [appError, setAppError] = useState(null);
@@ -256,7 +257,6 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
},
});
const validateIntegration = clientApi.onboard.testIntegration.useMutation();
- const isApplying = complete.isPending || validateIntegration.isPending;
const dockerData = docker.data;
const discoveredIntegrations = dockerData?.integrations ?? emptyDiscoveredIntegrations;
@@ -280,6 +280,10 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
[discoveredIntegrations],
);
+ useEffect(() => {
+ setServerOrigin((current) => current || window.location.origin);
+ }, []);
+
useEffect(() => {
if (!docker.data) return;
const newIntegrationSourceIds = takeNewSourceIds(
@@ -412,6 +416,8 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
};
const applySetupAsync = async () => {
+ if (isApplying) return;
+ setIsApplying(true);
setAppError(null);
setAppErrorSourceId(null);
setApplyProgress(5);
@@ -438,19 +444,15 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
setApplyMessage(t("review.progress.applying"));
for (const [index, draft] of draftsToApply.entries()) {
setApplyProgress(5 + Math.round(((index + 1) / Math.max(1, draftsToApply.length)) * 15));
- try {
- const result = await validateIntegration.mutateAsync({
+ await validateIntegration
+ .mutateAsync({
sourceId: draft.sourceId ?? draft.id,
name: draft.name,
url: normalizeServiceUrl(draft.url) ?? draft.url,
kind: draft.kind,
secrets: draft.secrets,
- });
- if (result.success) sounds.success();
- else sounds.error();
- } catch {
- sounds.error();
- }
+ })
+ .catch(() => undefined);
}
setApplyProgress(20);
await new Promise((resolve) => setTimeout(resolve, 300));
@@ -543,6 +545,7 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
} catch {
sounds.error();
setApplyProgress(0);
+ setIsApplying(false);
}
};
@@ -1439,6 +1442,7 @@ const formatSecretKind = (kind: IntegrationSecretKind) =>
const BoardBuilder = (props: StudioSectionProps) => {
const t = useScopedI18n("init.studio.board");
+ const sounds = useOnboardingSounds();
const previewWidgetKinds = getPreviewWidgetKinds(props);
const previewAppCount = getPreviewAppCount(props);
const colorsCustomized =
@@ -1495,6 +1499,7 @@ const BoardBuilder = (props: StudioSectionProps) => {
size="compact-sm"
leftSection={}
onClick={() => {
+ sounds.pop();
props.setPrimaryColor(initialPrimaryColor);
props.setSecondaryColor(initialSecondaryColor);
}}
diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json
index d282243da..7a3e5d2ff 100644
--- a/packages/translation/src/lang/en.json
+++ b/packages/translation/src/lang/en.json
@@ -31,7 +31,7 @@
"title": "Make home feel organized.",
"description": "Welcome to Homarr v{version}. We’ll guide you through the essentials and help you build your first board.",
"start": "Get started",
- "restore": "Come from a backup",
+ "restore": "Restore backup",
"restoreTitle": "Restore this Homarr",
"restoreDescription": "A full SQLite backup restores boards, people, apps, integrations, and settings. Homarr validates it before replacing anything.",
"errorTitle": "Setup could not start",