From 8dd6cbbfdb4627c96ae674d40fb4eb1de3e881bf Mon Sep 17 00:00:00 2001 From: ajnart Date: Mon, 10 Aug 2026 19:48:40 +0200 Subject: [PATCH 1/3] fix assistant focus and settings --- .../assistant-configuration.module.css | 3 +- .../_components/assistant-configuration.tsx | 62 +------------ .../assistant/assistant-panel.module.css | 14 ++- .../assistant-runtime-provider.spec.ts | 48 ++++++++++- .../assistant/assistant-runtime-provider.tsx | 86 +++++++++++++++++-- .../components/assistant/assistant-widget.tsx | 6 +- packages/translation/src/lang/en.json | 15 +--- 7 files changed, 146 insertions(+), 88 deletions(-) diff --git a/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.module.css b/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.module.css index 1aa50dd4d..7f7d0d672 100644 --- a/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.module.css +++ b/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.module.css @@ -94,7 +94,8 @@ } .securityNote { - max-width: 66ch; + min-width: 0; + flex: 1; } .flowActions { diff --git a/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.tsx b/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.tsx index 61f05fefa..cbda5efe0 100644 --- a/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.tsx +++ b/apps/nextjs/src/app/[locale]/manage/assistant/_components/assistant-configuration.tsx @@ -13,7 +13,6 @@ import { Group, Image, LoadingOverlay, - Modal, Paper, PasswordInput, Select, @@ -31,7 +30,6 @@ import { IconCircleCheck, IconDatabaseSearch, IconKey, - IconLock, IconPlus, IconSearch, IconShieldCheck, @@ -154,7 +152,6 @@ export const AssistantConfiguration = () => { const [modelId, setModelId] = useState(""); const [enabled, setEnabled] = useState(false); const [webSearchEnabled, setWebSearchEnabled] = useState(false); - const [clearCredentialsOpened, setClearCredentialsOpened] = useState(false); useEffect(() => { setProvider(configuration?.provider ?? "openrouter"); @@ -285,27 +282,7 @@ export const AssistantConfiguration = () => { onError: (error) => showErrorNotification({ title: t("notification.error.title"), message: error.message }), }); - const clearCredentials = clientApi.assistant.clearCredentials.useMutation({ - onSuccess: async () => { - setClearCredentialsOpened(false); - setApiKey(""); - setHeaders([]); - setModelId(""); - setEnabled(false); - await Promise.all([ - utils.assistant.getAdminConfiguration.invalidate(), - utils.assistant.discoverModels.invalidate(), - utils.assistant.getAvailability.invalidate(), - ]); - showSuccessNotification({ - title: t("notification.credentialsCleared.title"), - message: t("notification.credentialsCleared.message"), - }); - }, - onError: (error) => showErrorNotification({ title: t("notification.error.title"), message: error.message }), - }); - - const pending = updateConnection.isPending || saveConfiguration.isPending || clearCredentials.isPending; + const pending = updateConnection.isPending || saveConfiguration.isPending; const resetCredentialFlow = () => { setApiKey(""); @@ -419,11 +396,6 @@ export const AssistantConfiguration = () => { {t("serverTools.webSearch.label")} )} - {hasStoredApiKey && ( - }> - {t("overview.keyProtected")} - - )} {configuration?.enabled === false && ( {t("overview.disabled")} @@ -508,15 +480,7 @@ export const AssistantConfiguration = () => { ) } actions={ - - + {credentialFlow === "idle" && ( - - - - ); }; diff --git a/apps/nextjs/src/components/assistant/assistant-panel.module.css b/apps/nextjs/src/components/assistant/assistant-panel.module.css index a834504fd..abda0bf52 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.module.css +++ b/apps/nextjs/src/components/assistant/assistant-panel.module.css @@ -113,7 +113,7 @@ border-radius: var(--mantine-radius-lg); color: var(--mantine-color-text); background: var(--mantine-color-body); - box-shadow: var(--mantine-shadow-xl); + box-shadow: none; animation: assistant-panel-in 180ms cubic-bezier(0.22, 1, 0.36, 1); } @@ -1310,7 +1310,17 @@ } .composer:has(.composerInput:focus-within) { - box-shadow: inset 0 0 0 2px var(--mantine-color-red-filled); + box-shadow: inset 0 0 0 2px var(--mantine-primary-color-filled); +} + +.floatingPanel .composer { + border-end-start-radius: var(--mantine-radius-lg); + border-end-end-radius: var(--mantine-radius-lg); +} + +.widgetPanel[data-board-widget] .composer { + border-end-start-radius: var(--assistant-widget-radius); + border-end-end-radius: var(--assistant-widget-radius); } .composerDropzone[data-dragging] .composer { diff --git a/apps/nextjs/src/components/assistant/assistant-runtime-provider.spec.ts b/apps/nextjs/src/components/assistant/assistant-runtime-provider.spec.ts index ea0b7d037..44cd03775 100644 --- a/apps/nextjs/src/components/assistant/assistant-runtime-provider.spec.ts +++ b/apps/nextjs/src/components/assistant/assistant-runtime-provider.spec.ts @@ -16,6 +16,7 @@ import { MessagePrimitive, ThreadPrimitive, useAui, + useAuiEvent, useLocalRuntime, } from "@assistant-ui/react"; import { LexicalComposerInput } from "@assistant-ui/react-lexical"; @@ -118,14 +119,25 @@ const SurfaceProbe = ({ id, onReady }: SurfaceProbeProps) => { } as ComponentProps); }; +const LexicalRunFocusProbe = () => { + useAuiEvent("thread.runStart", () => { + queueMicrotask(() => { + document.querySelector('[data-testid="input-b"] [contenteditable="true"]')?.focus(); + }); + }); + return null; +}; + const SurfaceTestAssistant = ({ onReady, onRuntimeReady, allowImages = true, + simulateRunFocus = false, }: { onReady: SurfaceProbeProps["onReady"]; onRuntimeReady: (runtime: AssistantRuntime) => void; allowImages?: boolean; + simulateRunFocus?: boolean; }) => { const runtime = useLocalRuntime(noOpAdapter, { adapters: { attachments: createAttachmentAdapter(allowImages ? "image/png,text/plain" : "text/plain") }, @@ -145,10 +157,14 @@ const SurfaceTestAssistant = ({ { surfaceId: "surface-b" }, createElement(SurfaceProbe, { id: "b", onReady }), ), + simulateRunFocus ? createElement(LexicalRunFocusProbe) : null, ); }; -const renderSurfaceTestAssistant = async ({ allowImages = true }: { allowImages?: boolean } = {}) => { +const renderSurfaceTestAssistant = async ({ + allowImages = true, + simulateRunFocus = false, +}: { allowImages?: boolean; simulateRunFocus?: boolean } = {}) => { const container = document.createElement("div"); containers.push(container); document.body.append(container); @@ -160,6 +176,7 @@ const renderSurfaceTestAssistant = async ({ allowImages = true }: { allowImages? root.render( createElement(SurfaceTestAssistant, { allowImages, + simulateRunFocus, onReady: (id, aui) => clients.set(id, aui), onRuntimeReady: (runtime) => { sharedRuntime = runtime; @@ -334,6 +351,35 @@ describe("AssistantRuntimeProviderWithTools", () => { await act(async () => root.unmount()); }); + test("keeps focus and dashboard scroll on the surface that started the run", async () => { + const { container, root, a } = await renderSurfaceTestAssistant({ simulateRunFocus: true }); + const panelInput = getRequiredComposerInput(container, "input-a"); + const widgetInput = getRequiredComposerInput(container, "input-b"); + const scrollContainer = container; + scrollContainer.scrollTop = 120; + const focusWidget = widgetInput.focus.bind(widgetInput); + const widgetFocus = vi.spyOn(widgetInput, "focus").mockImplementation((options) => { + // jsdom does not implement native focus scrolling, so reproduce the dashboard jump that a + // browser performs when the off-screen widget composer receives programmatic focus. + scrollContainer.scrollTop = 640; + focusWidget(options); + }); + panelInput.focus(); + + act(() => { + a.composer().setText("Send from the floating panel"); + a.composer().send(); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(widgetFocus).toHaveBeenCalled(); + expect(document.activeElement).toBe(panelInput); + expect(scrollContainer.scrollTop).toBe(120); + await act(async () => root.unmount()); + }); + test("adds pasted images only to the active surface composer", async () => { const { container, root, a, b } = await renderSurfaceTestAssistant(); const target = getRequiredComposerInput(container, "input-a"); diff --git a/apps/nextjs/src/components/assistant/assistant-runtime-provider.tsx b/apps/nextjs/src/components/assistant/assistant-runtime-provider.tsx index 42e5342b2..e849c2393 100644 --- a/apps/nextjs/src/components/assistant/assistant-runtime-provider.tsx +++ b/apps/nextjs/src/components/assistant/assistant-runtime-provider.tsx @@ -378,6 +378,42 @@ export const useAssistantComposerSurface = () => { }; const surfaceElements = new Map(); +let lastFocusedSurfaceElement: HTMLElement | null = null; +let lastFocusedSurfaceAt = 0; + +interface AssistantSurfaceScrollPosition { + element: HTMLElement; + left: number; + top: number; +} + +const captureAssistantSurfaceScrollPositions = () => { + const elements = new Set(); + for (const surfaceElement of surfaceElements.values()) { + let element: HTMLElement | null = surfaceElement; + while (element) { + elements.add(element); + element = element.parentElement; + } + } + + return Array.from( + elements, + (element): AssistantSurfaceScrollPosition => ({ + element, + left: element.scrollLeft, + top: element.scrollTop, + }), + ); +}; + +const restoreAssistantSurfaceScrollPositions = (positions: AssistantSurfaceScrollPosition[]) => { + for (const { element, left, top } of positions) { + if (!element.isConnected) continue; + element.scrollLeft = left; + element.scrollTop = top; + } +}; const getSurfaceIdForElement = (element: Element | null) => { if (!element) return null; @@ -461,7 +497,16 @@ const AssistantComposerSurfaceBoundary = ({ children, surfaceId }: PropsWithChil return ( -
+
{ + if (!(event.target instanceof HTMLElement)) return; + lastFocusedSurfaceElement = event.target; + lastFocusedSurfaceAt = performance.now(); + }} + onPasteCapture={handlePasteCapture} + > {children}
@@ -476,19 +521,42 @@ const AssistantComposerSurfaceBoundary = ({ children, surfaceId }: PropsWithChil */ export const AssistantRunFocusPreserver = () => { useAuiEvent("thread.runStart", () => { - const focusedBeforeRun = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const activeElement = document.activeElement instanceof HTMLElement ? document.activeElement : null; + // Sending can briefly move focus to while Lexical reconciles the editor. Retain the + // last connected assistant input so the run still belongs to the surface the user submitted. + const recentlyFocusedSurface = performance.now() - lastFocusedSurfaceAt < 1000; + const focusedBeforeRun = + activeElement && activeElement !== document.body + ? activeElement + : recentlyFocusedSurface && lastFocusedSurfaceElement?.isConnected + ? lastFocusedSurfaceElement + : activeElement; const surfaceIdBeforeRun = getSurfaceIdForElement(focusedBeforeRun); + const scrollPositionsBeforeRun = captureAssistantSurfaceScrollPositions(); + let isRestoring = false; const restoreFocus = () => { + if (isRestoring) return; const surfaceIdAfterRun = getSurfaceIdForElement(document.activeElement); - if (!surfaceIdAfterRun || surfaceIdAfterRun === surfaceIdBeforeRun) return; + const movedToAnotherSurface = surfaceIdAfterRun !== null && surfaceIdAfterRun !== surfaceIdBeforeRun; + const leftInitiatingSurface = surfaceIdBeforeRun !== null && surfaceIdAfterRun !== surfaceIdBeforeRun; + if (!movedToAnotherSurface && !leftInitiatingSurface) return; - if (focusedBeforeRun?.isConnected) focusedBeforeRun.focus({ preventScroll: true }); - if ( - getSurfaceIdForElement(document.activeElement) !== surfaceIdBeforeRun && - document.activeElement instanceof HTMLElement - ) { - document.activeElement.blur(); + isRestoring = true; + try { + if (focusedBeforeRun?.isConnected) focusedBeforeRun.focus({ preventScroll: true }); + if ( + getSurfaceIdForElement(document.activeElement) !== surfaceIdBeforeRun && + document.activeElement instanceof HTMLElement + ) { + document.activeElement.blur(); + } + // Focusing the other mounted Lexical composer may already have scrolled the dashboard or + // one of its nested board containers. Put every assistant surface ancestor back before the + // browser paints so a send from the floating panel never jumps to the board widget. + restoreAssistantSurfaceScrollPositions(scrollPositionsBeforeRun); + } finally { + isRestoring = false; } }; diff --git a/apps/nextjs/src/components/assistant/assistant-widget.tsx b/apps/nextjs/src/components/assistant/assistant-widget.tsx index 898023337..e00ee137f 100644 --- a/apps/nextjs/src/components/assistant/assistant-widget.tsx +++ b/apps/nextjs/src/components/assistant/assistant-widget.tsx @@ -1,6 +1,6 @@ "use client"; -import type { ReactNode } from "react"; +import type { CSSProperties, ReactNode } from "react"; import { useCallback, useState } from "react"; import { useAui, useAuiState } from "@assistant-ui/react"; import { ActionIcon, Box, Button, Center, Group, Loader, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core"; @@ -8,6 +8,7 @@ import { IconAlertTriangle, IconArrowsMaximize, IconMessage, IconRobot } from "@ import { useI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "@homarr/widgets"; +import { useRequiredBoard } from "@homarr/boards/context"; import classes from "./assistant-panel.module.css"; import { AssistantConversationSurface } from "./assistant-panel"; @@ -31,6 +32,7 @@ export const AssistantBoardWidget = (props: WidgetComponentProps<"assistant">) = const EnabledAssistantBoardWidget = ({ options, width, height, isEditMode }: WidgetComponentProps<"assistant">) => { const t = useI18n(); + const board = useRequiredBoard(); const assistant = useHomarrAssistant(); const preferences = useAssistantPreferences(); const aui = useAui(); @@ -110,9 +112,11 @@ const EnabledAssistantBoardWidget = ({ options, width, height, isEditMode }: Wid return ( Date: Mon, 10 Aug 2026 20:07:51 +0200 Subject: [PATCH 2/3] feat(workshop): add social previews --- apps/docs/docs/management/workshop/index.mdx | 2 + .../src/components/workshop/DetailPage.tsx | 24 ++++++++ apps/workshop/pb_hooks/workshop-utils.js | 32 +++++++++++ apps/workshop/pb_hooks/workshop.pb.js | 40 ++++++++++++++ apps/workshop/test-image.sh | 7 +++ apps/workshop/tests/workshop-contracts.mjs | 29 ++++++++++ .../workshop-social-metadata.integration.mjs | 55 +++++++++++++++++++ 7 files changed, 189 insertions(+) create mode 100644 apps/workshop/tests/workshop-social-metadata.integration.mjs diff --git a/apps/docs/docs/management/workshop/index.mdx b/apps/docs/docs/management/workshop/index.mdx index 24357f761..97c26c40a 100644 --- a/apps/docs/docs/management/workshop/index.mdx +++ b/apps/docs/docs/management/workshop/index.mdx @@ -93,6 +93,8 @@ Custom Widget imports detect required bearer, basic, header, or query credential Workshop is powered by [PocketBase](https://pocketbase.io). The production image compiles the current Docusaurus site and serves it together with the API from one origin. The deployment lives in `apps/workshop/` with a Dockerfile, Compose configuration, version-controlled migrations, hooks, a smoke test, and ACT-local workflow instructions. +Submission links include Open Graph and X/Twitter metadata for the submission title, description, and type. The first uploaded screenshot is used as the social preview image; submissions without screenshots use the Homarr logo. + Run `pnpm docker:workshop` for PocketBase on port 8090, then run `WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs` for Docusaurus on port 3003. `pnpm docker:docs` builds the combined production layout. Normal start and stop commands retain data; `down --volumes` is an intentional, diff --git a/apps/docs/src/components/workshop/DetailPage.tsx b/apps/docs/src/components/workshop/DetailPage.tsx index 579dd65a9..c806f3f81 100644 --- a/apps/docs/src/components/workshop/DetailPage.tsx +++ b/apps/docs/src/components/workshop/DetailPage.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import BrowserOnly from "@docusaurus/BrowserOnly"; +import Head from "@docusaurus/Head"; import Link from "@docusaurus/Link"; import { useLocation } from "@docusaurus/router"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; @@ -69,6 +70,7 @@ import { WorkshopErrorBoundary } from "./WorkshopErrorBoundary"; import { downloadSubmissionJson, voteAndReconcile } from "./workshop-utils"; const typeLabels: Record = { customCss: "CSS", customWidget: "Widget" }; +const typeSocialLabels: Record = { customCss: "Custom CSS", customWidget: "Custom widget" }; const typeDotColors: Record = { customCss: "bg-blue-500", customWidget: "bg-yellow-500" }; const contentLanguages: Record = { customCss: "css", customWidget: "json" }; const copyState = [ @@ -536,9 +538,31 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => { const options = widgetDefinition ? Object.entries(widgetDefinition.options) : []; const protectedSources = sources.filter(([, source]) => sourceNeedsSecret(source.auth)); const displayedContent = formatWorkshopContent(submission.type, submission.content); + const socialTitle = `${submission.title} · Homarr Workshop`; + const socialDescription = `${typeSocialLabels[submission.type]} for Homarr. ${submission.description}`.trim(); + const socialUrl = `${window.location.origin}${window.location.pathname}`; + const socialImage = screenshotUrls[0] ?? `${window.location.origin}/img/logo.png`; return (
+ + {socialTitle} + + + + + + + + + + + + + + + + .replaceAll('"', """) .replaceAll("'", "'"); +const renderWorkshopSocialHtml = (html, metadata) => { + const title = escapeHtml(metadata.title); + const description = escapeHtml(metadata.description); + const url = escapeHtml(metadata.url); + const image = escapeHtml(metadata.image); + const imageAlt = escapeHtml(`${metadata.submissionTitle} preview`); + const section = escapeHtml(metadata.section); + const tags = [ + `${title}`, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ].join("\n"); + const replaceableTags = + /]*>[\s\S]*?<\/title>|]*(?:name|property)=["'](?:description|og:(?:title|description|url|image|image:alt|type|site_name)|twitter:(?:card|title|description|image|image:alt)|article:section)["'][^>]*>|]*rel=["']canonical["'][^>]*>/gi; + + return html.replace(replaceableTags, "").replace("", `\n${tags}`); +}; + const emailTemplate = (filename) => { const hooksPath = $os.getenv("WORKSHOP_PB_HOOKS_DIR") || __hooks; return $os.readFile($filepath.join(hooksPath, filename)); @@ -32,5 +63,6 @@ module.exports = { emailTemplate, escapeHtml, rejectRequest, + renderWorkshopSocialHtml, sendEmail, }; diff --git a/apps/workshop/pb_hooks/workshop.pb.js b/apps/workshop/pb_hooks/workshop.pb.js index 988d6f278..6f8634f7e 100644 --- a/apps/workshop/pb_hooks/workshop.pb.js +++ b/apps/workshop/pb_hooks/workshop.pb.js @@ -17,6 +17,46 @@ onBootstrap((event) => { console.log(JSON.stringify({ event: "workshop_oauth_synchronized", enabled: configured })); }); +const workshopDetailPage = (event) => { + const id = event.request.pathValue("id"); + if (id === "admin") return event.fileFS($os.dirFS("/pb_public"), "workshop/admin/index.html"); + + const indexHtml = toString($os.readFile("/pb_public/index.html")); + try { + const { renderWorkshopSocialHtml } = require(`${__hooks}/workshop-utils.js`); + const submission = event.app.findRecordById("submissions", id); + const type = submission.getString("type"); + const section = type === "customCss" ? "Custom CSS" : "Custom widget"; + const submissionTitle = submission.getString("title"); + const title = `${submissionTitle} · Homarr Workshop`; + const description = `${section} for Homarr. ${submission.getString("description")}`.trim(); + const workshopUrl = $os.getenv("WORKSHOP_WEB_URL").replace(/\/$/, ""); + const apiUrl = $os.getenv("WORKSHOP_API_URL").replace(/\/$/, ""); + const websiteUrl = $os.getenv("HOMARR_WEBSITE_URL").replace(/\/$/, ""); + const screenshot = submission.getStringSlice("screenshots")[0]; + const image = screenshot + ? `${apiUrl}/api/files/submissions/${submission.id}/${encodeURIComponent(screenshot)}` + : `${websiteUrl}/img/logo.png`; + + return event.html( + 200, + renderWorkshopSocialHtml(indexHtml, { + title, + description, + url: `${workshopUrl}/${submission.id}`, + image, + section, + submissionTitle, + }), + ); + } catch { + return event.html(200, indexHtml); + } +}; + +routerAdd("GET", "/workshop/{id}", workshopDetailPage); +routerAdd("GET", "/workshop/{id}/{$}", workshopDetailPage); + onRecordCreateRequest((event) => { event.record.set("revision", 1); event.record.set("expectedRevision", 0); diff --git a/apps/workshop/test-image.sh b/apps/workshop/test-image.sh index c56e3b15e..f0fc2a22b 100644 --- a/apps/workshop/test-image.sh +++ b/apps/workshop/test-image.sh @@ -38,6 +38,13 @@ WORKSHOP_TEST_URL="http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT" \ EXPECTED_WORKSHOP_WEB_URL="$PREVIEW_ORIGIN/workshop" \ node apps/workshop/tests/runtime-config.integration.mjs +docker exec "$WORKSHOP_IMAGE_TEST_NAME" pocketbase superuser create \ + workshop-image@example.invalid 'WorkshopImageTest123!' --dir=/pb_data +WORKSHOP_TEST_URL="http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT" \ + EXPECTED_WORKSHOP_API_URL="$PREVIEW_ORIGIN" \ + EXPECTED_WORKSHOP_WEB_URL="$PREVIEW_ORIGIN/workshop" \ + node apps/workshop/tests/workshop-social-metadata.integration.mjs + curl --fail --location --silent "http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT/workshop" >/dev/null curl --fail --silent \ "http://127.0.0.1:$WORKSHOP_IMAGE_TEST_PORT/api/collections/workshop_listings/records?perPage=1" >/dev/null diff --git a/apps/workshop/tests/workshop-contracts.mjs b/apps/workshop/tests/workshop-contracts.mjs index 6d8bf3106..3b72bb541 100644 --- a/apps/workshop/tests/workshop-contracts.mjs +++ b/apps/workshop/tests/workshop-contracts.mjs @@ -1,5 +1,6 @@ import { access, readFile } from "node:fs/promises"; import { resolve } from "node:path"; +import vm from "node:vm"; const read = (path) => readFile(resolve(path), "utf8"); @@ -39,6 +40,34 @@ for (const artifact of [ if (hook.includes('findAllRecords("users")') || !hook.includes('findRecordsByFilter("users"')) { throw new Error("Workshop report notifications must use a filtered, bounded administrator query"); } +if (!hook.includes('routerAdd("GET", "/workshop/{id}"') || !hook.includes('getStringSlice("screenshots")')) { + throw new Error("Workshop detail routes must render server-side social metadata with submission screenshots"); +} + +const hookUtilsModule = { exports: {} }; +vm.runInNewContext(await read("apps/workshop/pb_hooks/workshop-utils.js"), { module: hookUtilsModule }); +const socialHtml = hookUtilsModule.exports.renderWorkshopSocialHtml( + 'Homarr documentation', + { + title: "Ocean · Homarr Workshop", + description: "Custom CSS for Homarr. Calm & readable.", + url: "https://preview.example/workshop/abc", + image: "https://preview.example/api/files/submissions/abc/preview.png", + section: "Custom CSS", + submissionTitle: "Ocean ", + }, +); +for (const expected of [ + "Ocean <Glow> · Homarr Workshop", + 'property="og:description" content="Custom CSS for Homarr. Calm & readable."', + 'rel="canonical" href="https://preview.example/workshop/abc"', + 'property="og:image" content="https://preview.example/api/files/submissions/abc/preview.png"', + 'name="twitter:card" content="summary_large_image"', + 'property="article:section" content="Custom CSS"', +]) { + if (!socialHtml.includes(expected)) throw new Error(`Workshop social metadata is missing: ${expected}`); +} +if (socialHtml.includes("Homarr documentation")) throw new Error("Workshop social metadata must replace generic tags"); const migration = await read("apps/workshop/pb_migrations/1784240000_workshop_widgets.js"); for (const removedField of ["displayName", "avatarUrl", "githubProfileUrl", "githubUsername"]) { diff --git a/apps/workshop/tests/workshop-social-metadata.integration.mjs b/apps/workshop/tests/workshop-social-metadata.integration.mjs new file mode 100644 index 000000000..2f198d8bc --- /dev/null +++ b/apps/workshop/tests/workshop-social-metadata.integration.mjs @@ -0,0 +1,55 @@ +const baseUrl = process.env.WORKSHOP_TEST_URL ?? "http://127.0.0.1:18091"; +const publicWorkshopUrl = process.env.EXPECTED_WORKSHOP_WEB_URL ?? "https://v2.preview.homarr.dev/workshop"; +const publicApiUrl = process.env.EXPECTED_WORKSHOP_API_URL ?? "https://v2.preview.homarr.dev"; + +const request = async (path, init = {}) => { + const response = await fetch(`${baseUrl}${path}`, init); + const body = await response.json().catch(() => null); + if (!response.ok) throw new Error(`${response.status} ${path}: ${JSON.stringify(body)}`); + return body; +}; + +const root = await request("/api/collections/_superusers/auth-with-password", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ identity: "workshop-image@example.invalid", password: "WorkshopImageTest123!" }), +}); +const headers = { authorization: `Bearer ${root.token}` }; +const author = await request("/api/collections/users/records", { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ + email: "social-author@example.invalid", + password: "WorkshopSocialTest123!", + passwordConfirm: "WorkshopSocialTest123!", + name: "social-author", + }), +}); +const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + "base64", +); +const data = new FormData(); +data.set("type", "customCss"); +data.set("title", "Ocean "); +data.set("description", "Calm & readable."); +data.set("widgetSchema", "homarr-custom-css-v1"); +data.set("content", "body { color: white; }"); +data.set("author", author.id); +data.set("screenshots", new File([png], "preview.png", { type: "image/png" })); +const submission = await request("/api/collections/submissions/records", { method: "POST", headers, body: data }); +const screenshot = encodeURIComponent(submission.screenshots[0]); +const html = await fetch(`${baseUrl}/workshop/${submission.id}`).then((response) => response.text()); + +for (const expected of [ + "Ocean <Glow> · Homarr Workshop", + 'property="og:description" content="Custom CSS for Homarr. Calm & readable."', + `rel="canonical" href="${publicWorkshopUrl}/${submission.id}"`, + `property="og:image" content="${publicApiUrl}/api/files/submissions/${submission.id}/${screenshot}"`, + 'name="twitter:card" content="summary_large_image"', + 'property="article:section" content="Custom CSS"', +]) { + if (!html.includes(expected)) throw new Error(`Rendered Workshop page is missing social metadata: ${expected}`); +} + +console.log("Workshop social metadata integration passed"); From 16dd25127699e57ec75be5e8ac1a8ca0fc48188e Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 10 Aug 2026 20:10:22 +0200 Subject: [PATCH 3/3] fix: disable app context menu --- .../src/components/board/items/widget-context-menu.tsx | 2 +- e2e/board-grid.spec.ts | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/nextjs/src/components/board/items/widget-context-menu.tsx b/apps/nextjs/src/components/board/items/widget-context-menu.tsx index 570f1964e..ce4ece543 100644 --- a/apps/nextjs/src/components/board/items/widget-context-menu.tsx +++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx @@ -37,7 +37,7 @@ export const WidgetContextMenu = ({ item, definition, widgetStateRef, children } const [isEditMode] = useEditMode(); const settings = useSettings(); - if (!session || !settings.enableRightClickOnWidgets || isEditMode) return <>{children}; + if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") return <>{children}; return ( diff --git a/e2e/board-grid.spec.ts b/e2e/board-grid.spec.ts index c6336e7e9..bfc719e79 100644 --- a/e2e/board-grid.spec.ts +++ b/e2e/board-grid.spec.ts @@ -151,6 +151,13 @@ describe("Board grid", () => { await nestedAppLink.click(); await expect(page).toHaveURL(/#board-app-opened$/); await page.evaluate(() => history.replaceState(null, "", `${location.pathname}${location.search}`)); + + await firstItem.click({ button: "right" }); + await expect(page.getByRole("menu")).toBeVisible(); + await page.keyboard.press("Escape"); + await nestedItem.click({ button: "right" }); + await expect(page.getByRole("menu")).toHaveCount(0); + const normalZoomTileBox = await expectBoundingBoxAsync(logicalTile); const secondItemBox = await expectBoundingBoxAsync(secondItem); expect(secondItemBox.x - (normalZoomTileBox.x + normalZoomTileBox.width)).toBeCloseTo(