diff --git a/.env.example b/.env.example index 6a2707907..e05862c3c 100644 --- a/.env.example +++ b/.env.example @@ -67,6 +67,9 @@ HOMARR_WEBSITE_URL=https://homarr.dev # PocketBase API used by Homarr and by the standalone documentation site. # Defaults to HOMARR_WEBSITE_URL. # WORKSHOP_API_URL=https://homarr.dev +# Public Workshop website used by links in Homarr, Custom Widgets, onboarding, +# and assistant context. Defaults to HOMARR_WEBSITE_URL/workshop. +# WORKSHOP_WEB_URL=https://homarr.dev/workshop # Documentation and PocketBase deployment settings. # WORKSHOP_PUBLIC_ORIGIN=https://homarr.dev @@ -75,6 +78,7 @@ HOMARR_WEBSITE_URL=https://homarr.dev # Local Workshop stack: # HOMARR_WEBSITE_URL=http://127.0.0.1:3003 # WORKSHOP_API_URL=http://127.0.0.1:8090 +# WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop # WORKSHOP_PUBLIC_ORIGIN=http://127.0.0.1:3003 # Optional: GITHUB_CLIENT_ID= # Optional: GITHUB_CLIENT_SECRET= diff --git a/apps/docs/docs/advanced/environment-variables/index.mdx b/apps/docs/docs/advanced/environment-variables/index.mdx index fda608c6f..140ce3873 100644 --- a/apps/docs/docs/advanced/environment-variables/index.mdx +++ b/apps/docs/docs/advanced/environment-variables/index.mdx @@ -25,16 +25,17 @@ This is especially useful for [docker secrets](https://docs.docker.com/engine/sw Using the `PUID` and `PGID` will require you to set the correct permissions on the mounted volumes and if used the docker socket. See more in the [Running as a different user](/docs/advanced/running-as-different-user) documentation. ::: -| Environment Variable | Description | Possible values | Default | -| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | -------------------- | -| `PUID` | User ID to run the container as | Any valid user ID | `0` | -| `PGID` | Group ID to run the container as | Any valid group ID | `0` | -| `LOG_LEVEL` | Log level to use | `debug` / `info` / `warn` / `error` | `info` | -| `NO_EXTERNAL_CONNECTION` | Disables Homarr's internet-backed requests, including update checks, weather, and RSS feeds | `true` or `false` | `false` | -| `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` | -| `HOMARR_DISABLE_IPV6` | Disables the IPv6 listener of the internal nginx proxy, even when the host supports IPv6. IPv6 is normally disabled automatically when the host has no IPv6 configured. See [#4596](https://github.com/homarr-labs/homarr/issues/4596) | `true` or `false` | `false` | -| `HOMARR_WEBSITE_URL` | Main Homarr documentation/site base used by runtime links. Must be an absolute HTTP(S) URL. | HTTP(S) URL | `https://homarr.dev` | -| `WORKSHOP_API_URL` | PocketBase API used by Homarr and standalone documentation. | HTTP(S) URL | `HOMARR_WEBSITE_URL` | +| Environment Variable | Description | Possible values | Default | +| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ----------------------------- | +| `PUID` | User ID to run the container as | Any valid user ID | `0` | +| `PGID` | Group ID to run the container as | Any valid group ID | `0` | +| `LOG_LEVEL` | Log level to use | `debug` / `info` / `warn` / `error` | `info` | +| `NO_EXTERNAL_CONNECTION` | Disables Homarr's internet-backed requests, including update checks, weather, and RSS feeds | `true` or `false` | `false` | +| `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` | +| `HOMARR_DISABLE_IPV6` | Disables the IPv6 listener of the internal nginx proxy, even when the host supports IPv6. IPv6 is normally disabled automatically when the host has no IPv6 configured. See [#4596](https://github.com/homarr-labs/homarr/issues/4596) | `true` or `false` | `false` | +| `HOMARR_WEBSITE_URL` | Main Homarr documentation/site base used by runtime links. Must be an absolute HTTP(S) URL. | HTTP(S) URL | `https://homarr.dev` | +| `WORKSHOP_API_URL` | PocketBase API used by Homarr and standalone documentation. | HTTP(S) URL | `HOMARR_WEBSITE_URL` | +| `WORKSHOP_WEB_URL` | Public Workshop website used by links in Homarr, Custom Widgets, onboarding, and assistant context. | HTTP(S) URL | `HOMARR_WEBSITE_URL/workshop` | Workshop service operators can find its server-only configuration in the [Workshop operator guide](../../workshop/operator#environment-variables). diff --git a/apps/docs/docs/management/custom-widgets/index.mdx b/apps/docs/docs/management/custom-widgets/index.mdx index 3f2418a16..80406808a 100644 --- a/apps/docs/docs/management/custom-widgets/index.mdx +++ b/apps/docs/docs/management/custom-widgets/index.mdx @@ -109,7 +109,7 @@ to be saved before server requests run. ## Import and Workshop -The install flow always links to the corresponding public Workshop entry under `HOMARR_WEBSITE_URL/workshop`. Import confirmation shows origins, authentication modes, network scopes, methods, actions, and permissions. Every source has a suggested URL, but private and loopback sources must be confirmed or replaced for the current Homarr installation. The same screen accepts API keys, tokens, usernames, and passwords; credentials remain optional at install time and missing values stay visible on the Custom Widgets page. +The install flow always links to the corresponding public Workshop entry under the configured Workshop origin. Import confirmation shows origins, authentication modes, network scopes, methods, actions, and permissions. Every source has a suggested URL, but private and loopback sources must be confirmed or replaced for the current Homarr installation. The same screen accepts API keys, tokens, usernames, and passwords; credentials remain optional at install time and missing values stay visible on the Custom Widgets page. For example, installing **Tautulli Activity** asks for both the user's Tautulli URL and API key. A stable public source such as PokéAPI keeps its published URL without creating unnecessary setup work. diff --git a/apps/docs/docs/workshop/development.mdx b/apps/docs/docs/workshop/development.mdx index 340da7a3f..a7622cbc3 100644 --- a/apps/docs/docs/workshop/development.mdx +++ b/apps/docs/docs/workshop/development.mdx @@ -47,13 +47,14 @@ Workshop moderator promotion, logs, tests, backup, restore, stopping, and intent ## URL testing -Docusaurus reads `HOMARR_WEBSITE_URL` and `WORKSHOP_API_URL` when it starts or builds. Homarr derives the public -Workshop page by appending `/workshop` to `HOMARR_WEBSITE_URL`, so these values do not rely on `NEXT_PUBLIC_*` build -variables. Test a separate API origin before release: +Docusaurus reads `HOMARR_WEBSITE_URL`, `WORKSHOP_API_URL`, and `WORKSHOP_WEB_URL` when it starts or builds. Homarr uses +`WORKSHOP_WEB_URL` for public Workshop links while API and documentation origins remain independent. These values do +not rely on `NEXT_PUBLIC_*` build variables. Test separate origins before release: ```sh HOMARR_WEBSITE_URL=http://127.0.0.1:3003 \ WORKSHOP_API_URL=http://127.0.0.1:8090 \ +WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop \ pnpm dev:docs ``` diff --git a/apps/docs/docs/workshop/index.mdx b/apps/docs/docs/workshop/index.mdx index cf5682b88..686866412 100644 --- a/apps/docs/docs/workshop/index.mdx +++ b/apps/docs/docs/workshop/index.mdx @@ -32,14 +32,12 @@ Homarr instance and are never included in a Workshop submission. ## Share your work -1. Sign in to the Workshop with GitHub. -2. Select **Share yours**. -3. Choose **Custom Widget** or **Custom CSS**. -4. Add the source, a short description, screenshots, and a changelog. -5. Review and publish the submission. +For a saved Custom Widget, open **Management → Custom Widgets**, use its action menu, and select **Publish to +Workshop**. Review the public listing details and source URLs, then publish the credential-free export. Custom CSS and +manual submissions can be shared directly from the Workshop website with **Share yours**. -Export Custom Widget JSON from **Management → Custom Widgets → Export**. You can edit a submission later without -changing its Workshop URL. +Publishing creates the listing immediately. To update or delete it, review reports, or mark it outdated, open the +Workshop website, sign in, and select **Yours**. Editing a submission does not change its Workshop URL. ## Voting, comments, and reports diff --git a/apps/docs/docs/workshop/operator.mdx b/apps/docs/docs/workshop/operator.mdx index d1d430e2a..9794f8a2a 100644 --- a/apps/docs/docs/workshop/operator.mdx +++ b/apps/docs/docs/workshop/operator.mdx @@ -37,21 +37,23 @@ store duplicate profile metadata or avatar files. Configure SMTP for comment, re The public URL contract and Workshop-only server configuration are: -| Variable | Purpose | Default | -| -------------------------------------- | ------------------------------------------------- | ------------------------------ | -| `HOMARR_WEBSITE_URL` | Documentation/site base used by Homarr links | `https://homarr.dev` | -| `WORKSHOP_API_URL` | PocketBase API used by Homarr and standalone docs | `HOMARR_WEBSITE_URL` | -| `WORKSHOP_PUBLIC_ORIGIN` | PocketBase-generated links and email | Required in production | -| `PB_ALLOWED_ORIGINS` | PocketBase browser origins | `*` for the central community | -| `OPENROUTER_API_KEY` | Server-only key for the Homarr provider proxy | Provider disabled when empty | -| `HOMARR_AI_DAILY_REQUEST_LIMIT` | Per-user daily provider allowance | `50` | -| `HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT` | Shared daily ceiling across all users | `10000` | -| `HOMARR_AI_OPENROUTER_BASE_URL` | OpenRouter-compatible proxy upstream | `https://openrouter.ai/api/v1` | -| `HOMARR_AI_OPENROUTER_MODEL` | Server-selected upstream model | Selected by the Homarr team | +| Variable | Purpose | Default | +| -------------------------------------- | --------------------------------------------- | ------------------------------ | +| `HOMARR_WEBSITE_URL` | Documentation/site base used by Homarr links | `https://homarr.dev` | +| `WORKSHOP_API_URL` | PocketBase API used by Homarr and docs | `HOMARR_WEBSITE_URL` | +| `WORKSHOP_WEB_URL` | Public Workshop links in Homarr | `HOMARR_WEBSITE_URL/workshop` | +| `WORKSHOP_PUBLIC_ORIGIN` | PocketBase-generated links and email | Required in production | +| `PB_ALLOWED_ORIGINS` | PocketBase browser origins | `*` for the central community | +| `OPENROUTER_API_KEY` | Server-only key for the Homarr provider proxy | Provider disabled when empty | +| `HOMARR_AI_DAILY_REQUEST_LIMIT` | Per-user daily provider allowance | `50` | +| `HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT` | Shared daily ceiling across all users | `10000` | +| `HOMARR_AI_OPENROUTER_BASE_URL` | OpenRouter-compatible proxy upstream | `https://openrouter.ai/api/v1` | +| `HOMARR_AI_OPENROUTER_MODEL` | Server-selected upstream model | Selected by the Homarr team | All public URL variables reject non-HTTP protocols, embedded credentials, query strings, and fragments, and normalize trailing slashes when Homarr or Docusaurus starts. The deprecated docs-only `WORKSHOP_URL` alias remains for one -release; `WORKSHOP_API_URL` takes precedence. The combined Workshop image writes the public URLs into its browser +release; `WORKSHOP_API_URL` takes precedence for API calls and `WORKSHOP_WEB_URL` takes precedence for public Workshop +links. The combined Workshop image writes the public URLs into its browser runtime configuration whenever the container starts; the Docker build arguments remain fallbacks. Restart the container after changing these values, without rebuilding it. diff --git a/apps/nextjs/src/app/[locale]/init/page.tsx b/apps/nextjs/src/app/[locale]/init/page.tsx index 58150782e..b754a2919 100644 --- a/apps/nextjs/src/app/[locale]/init/page.tsx +++ b/apps/nextjs/src/app/[locale]/init/page.tsx @@ -48,6 +48,7 @@ export default async function InitPage() { ? resolveHomarrUrlConfig({ homarrWebsiteUrl: env.HOMARR_WEBSITE_URL, workshopApiUrl: env.WORKSHOP_API_URL, + workshopWebUrl: env.WORKSHOP_WEB_URL, }) : { workshopApiUrl: "", workshopWebUrl: "" }; const baseUrl = extractBaseUrlFromHeaders(requestHeaders); diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index cbc64af1f..3baca0b39 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -121,6 +121,7 @@ export default async function Layout(props: { const publicUrls = resolveHomarrUrlConfig({ homarrWebsiteUrl: env.HOMARR_WEBSITE_URL, workshopApiUrl: env.WORKSHOP_API_URL, + workshopWebUrl: env.WORKSHOP_WEB_URL, }); const StackedProvider = composeWrappers([ diff --git a/apps/nextjs/src/app/[locale]/manage/workshop/page.tsx b/apps/nextjs/src/app/[locale]/manage/workshop/page.tsx index e53aa3080..127564ccc 100644 --- a/apps/nextjs/src/app/[locale]/manage/workshop/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/workshop/page.tsx @@ -1,6 +1,6 @@ import { redirect } from "next/navigation"; -import { Button } from "@mantine/core"; -import { IconExternalLink } from "@tabler/icons-react"; +import { Alert, Button, Stack } from "@mantine/core"; +import { IconExternalLink, IconInfoCircle } from "@tabler/icons-react"; import { auth } from "@homarr/auth/next"; import { getScopedI18n } from "@homarr/translation/server"; @@ -17,6 +17,7 @@ export default async function WorkshopPage() { const { workshopWebUrl } = resolveHomarrUrlConfig({ homarrWebsiteUrl: env.HOMARR_WEBSITE_URL, workshopApiUrl: env.WORKSHOP_API_URL, + workshopWebUrl: env.WORKSHOP_WEB_URL, }); return ( } > - + + } title={t("manageSubmissionsTitle")} color="blue"> + {t("manageSubmissionsDescription")} + + + ); } diff --git a/apps/nextjs/src/app/api/assistant/chat/assistant-chat-input.spec.ts b/apps/nextjs/src/app/api/assistant/chat/assistant-chat-input.spec.ts index 0dbf009a4..3ce89a2c8 100644 --- a/apps/nextjs/src/app/api/assistant/chat/assistant-chat-input.spec.ts +++ b/apps/nextjs/src/app/api/assistant/chat/assistant-chat-input.spec.ts @@ -35,6 +35,7 @@ describe("assistant chat input", () => { clientContext: { pathname: "/en/boards/Home/settings", timeZone: "Europe/Paris" }, currentTime: new Date("2026-08-13T08:00:00.000Z"), userName: "Alex", + workshopWebUrl: "https://community.example.com/workshop", entities: [ { id: "board-1", type: "board", label: "Home", description: "Home board" }, { id: "app-1", type: "app", label: "Jellyfin", description: "Media" }, @@ -51,6 +52,7 @@ describe("assistant chat input", () => { expect(context).toContain('"currentTimeUtc":"2026-08-13T08:00:00.000Z"'); expect(context).toContain('"currentUser":"Alex"'); + expect(context).toContain('"workshopWebUrl":"https://community.example.com/workshop"'); expect(context).toContain('"currentBoard":{"id":"board-1","name":"Home"}'); expect(context).toContain('"availableResources":{"boards":1,"apps":1,"integrations":1,"widgets":1}'); expect(context).toContain('"explicitMentions":[{"type":"app","id":"app-1","label":"Jellyfin"'); diff --git a/apps/nextjs/src/app/api/assistant/chat/assistant-chat-input.ts b/apps/nextjs/src/app/api/assistant/chat/assistant-chat-input.ts index 5f9f3e10e..e21d008d5 100644 --- a/apps/nextjs/src/app/api/assistant/chat/assistant-chat-input.ts +++ b/apps/nextjs/src/app/api/assistant/chat/assistant-chat-input.ts @@ -59,12 +59,14 @@ export const buildAssistantRequestContext = ({ entities, messages, userName, + workshopWebUrl, }: { clientContext?: AssistantClientContext; currentTime: Date; entities: AssistantContextEntity[]; messages: { role: "user" | "assistant"; parts: unknown[] }[]; userName: string | null | undefined; + workshopWebUrl?: string; }) => { const boards = entities.filter((entity) => entity.type === "board"); const homeBoard = boards.find((board) => board.description === "Home board"); @@ -88,6 +90,7 @@ export const buildAssistantRequestContext = ({ currentTimeUtc: currentTime.toISOString(), userTimeZone: clientContext?.timeZone ?? "UTC", currentUser: userName?.trim() || "Signed-in user", + workshopWebUrl: workshopWebUrl ?? null, currentPage: clientContext?.pathname ?? null, currentBoard: currentBoard ? { id: currentBoard.id, name: currentBoard.label } : null, homeBoard: homeBoard ? { id: homeBoard.id, name: homeBoard.label } : null, diff --git a/apps/nextjs/src/app/api/assistant/chat/route.ts b/apps/nextjs/src/app/api/assistant/chat/route.ts index 024e89cab..d59733da0 100644 --- a/apps/nextjs/src/app/api/assistant/chat/route.ts +++ b/apps/nextjs/src/app/api/assistant/chat/route.ts @@ -24,6 +24,7 @@ import { assistantProviderRequiresApiKey, assistantReasoningModes, } from "@homarr/definitions"; +import { resolveHomarrUrlConfig } from "@homarr/workshop/schema"; import { browserToolContracts } from "~/components/assistant/assistant-tool-contracts"; import { env as appEnv } from "~/env"; @@ -461,6 +462,11 @@ export async function POST(request: Request) { entities: contextEntities, messages: parsed.data.messages, userName: session.user.name, + workshopWebUrl: resolveHomarrUrlConfig({ + homarrWebsiteUrl: appEnv.HOMARR_WEBSITE_URL, + workshopApiUrl: appEnv.WORKSHOP_API_URL, + workshopWebUrl: appEnv.WORKSHOP_WEB_URL, + }).workshopWebUrl, }); if (modelLookup.error !== null) { logger.warn("Assistant model discovery failed before the response started", { diff --git a/apps/nextjs/src/components/workshop/workshop-install-flow.spec.ts b/apps/nextjs/src/components/workshop/workshop-install-flow.spec.ts index b9fab824b..ce8943787 100644 --- a/apps/nextjs/src/components/workshop/workshop-install-flow.spec.ts +++ b/apps/nextjs/src/components/workshop/workshop-install-flow.spec.ts @@ -17,6 +17,15 @@ describe("Workshop widget install flow", () => { expect(getWorkshopWebUrl("widget/id")).toBe("https://preview.example/workshop/widget%2Fid"); }); + it("uses the runtime Workshop web URL independently from the API origin", () => { + document.head.innerHTML = [ + '', + '', + ].join(""); + + expect(getWorkshopWebUrl()).toBe("https://community.example.com/workshop"); + }); + it("derives the Homarr provider endpoint from the runtime Workshop API", () => { document.head.innerHTML = ''; diff --git a/apps/nextjs/src/components/workshop/workshop-publish-modal.tsx b/apps/nextjs/src/components/workshop/workshop-publish-modal.tsx index 24485f6a4..530128891 100644 --- a/apps/nextjs/src/components/workshop/workshop-publish-modal.tsx +++ b/apps/nextjs/src/components/workshop/workshop-publish-modal.tsx @@ -1,7 +1,24 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { Alert, Button, Checkbox, FileInput, Group, Modal, Stack, Textarea, TextInput } from "@mantine/core"; +import { + Alert, + Box, + Button, + Checkbox, + Divider, + FileInput, + Group, + Modal, + Paper, + Stack, + Text, + Textarea, + TextInput, + ThemeIcon, + Title, +} from "@mantine/core"; +import { IconBuildingStore, IconCheck, IconExternalLink, IconPhoto, IconShieldCheck } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { showErrorNotification } from "@homarr/notifications"; @@ -10,7 +27,7 @@ import { workshopScreenshotsSchema } from "@homarr/workshop/schema"; import type { WorkshopUser } from "@homarr/workshop/schema"; import { useScopedI18n } from "@homarr/translation/client"; -import { createWorkshopClient } from "./workshop-client"; +import { createWorkshopClient, getWorkshopWebUrl } from "./workshop-client"; import { getPrivateWorkshopSourceNames, publishWorkshopDefinition, @@ -34,7 +51,7 @@ export function WorkshopPublishModal({ const [screenshots, setScreenshots] = useState([]); const createSubmission = useWorkshopCreateMutation(client); const [error, setError] = useState(null); - const [published, setPublished] = useState(false); + const [publishedSubmissionId, setPublishedSubmissionId] = useState(null); const [sourceUrlsReviewed, setSourceUrlsReviewed] = useState(false); const definition = clientApi.customWidget.export.useQuery({ id: widget.id }, { enabled: opened }); const privateSourceNames = getPrivateWorkshopSourceNames(definition.data); @@ -51,7 +68,7 @@ export function WorkshopPublishModal({ setDescription(""); setScreenshots([]); setError(null); - setPublished(false); + setPublishedSubmissionId(null); setSourceUrlsReviewed(false); }, [opened, widget.name]); useEffect(() => setSourceUrlsReviewed(false), [definitionFingerprint]); @@ -64,11 +81,12 @@ export function WorkshopPublishModal({ throw new Error(t("publish.invalidScreenshot")); } const inspectedDefinition = definition.data; + let submissionId: string | null = null; const result = await publishWorkshopDefinition({ inspectedDefinition, refetchDefinition: async () => (await definition.refetch()).data, - publish: (content) => - createSubmission.mutateAsync({ + publish: async (content) => { + const submission = await createSubmission.mutateAsync({ input: { type: "customWidget", title, @@ -76,7 +94,9 @@ export function WorkshopPublishModal({ content, }, screenshots, - }), + }); + submissionId = submission.id; + }, }); if (result === "unavailable") throw new Error(t("publish.error")); if (result === "changed") { @@ -84,19 +104,72 @@ export function WorkshopPublishModal({ setError(t("publish.definitionChanged")); return; } - setPublished(true); + if (!submissionId) throw new Error(t("publish.error")); + setPublishedSubmissionId(submissionId); } catch (cause) { setError(cause instanceof Error ? cause.message : t("publish.error")); } }; return ( - - - {published ? ( - {t("publish.success")} + + + {publishedSubmissionId ? ( + + + + + + {t("publish.successTitle")} + + {t("publish.success")} + + + + + + + + + + {t("publish.manageTitle")} + + + {t("publish.manageDescription")} + + + + + + + + + ) : ( <> + + + + + + + {t("publish.introTitle")} + + {t("publish.introDescription")} + + + + {!user && ( @@ -118,28 +191,45 @@ export function WorkshopPublishModal({ )} - setTitle(event.currentTarget.value)} - required - /> -