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",