From 0ec5f36050c8ac448b53945b370eeef0970a7fb1 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 16 Aug 2026 21:25:08 +0200 Subject: [PATCH 01/12] ci: publish release v2 test image --- .github/workflows/ci.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5fe6ae745..0d24b8267 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: types: [closed] branches: ["**"] push: - branches: [main, dev, beta, next, v2] + branches: [main, dev, beta, next, v2, release/v2] merge_group: workflow_dispatch: inputs: @@ -108,6 +108,7 @@ jobs: needs: fast-gate if: | github.event_name == 'merge_group' || + (github.event_name == 'push' && github.ref == 'refs/heads/release/v2') || (github.event_name == 'pull_request' && github.event.pull_request.draft == false) || (github.event_name == 'workflow_dispatch' && (inputs.operation == 'validate' || inputs.operation == 'publish')) runs-on: ubuntu-latest @@ -204,6 +205,7 @@ jobs: !env.ACT && ( (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || + (github.event_name == 'push' && github.ref == 'refs/heads/release/v2') || (github.event_name == 'workflow_dispatch' && inputs.operation == 'publish') ) uses: docker/login-action@v4 @@ -218,6 +220,7 @@ jobs: !env.ACT && ( (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || + (github.event_name == 'push' && github.ref == 'refs/heads/release/v2') || (github.event_name == 'workflow_dispatch' && inputs.operation == 'publish') ) uses: docker/build-push-action@v7 @@ -243,6 +246,7 @@ jobs: if: | ( (github.event_name == 'pull_request' && github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository) || + (github.event_name == 'push' && github.ref == 'refs/heads/release/v2') || (github.event_name == 'workflow_dispatch' && inputs.operation == 'publish') ) runs-on: ubuntu-24.04-arm @@ -294,6 +298,7 @@ jobs: if: | ( (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || + (github.event_name == 'push' && github.ref == 'refs/heads/release/v2') || (github.event_name == 'workflow_dispatch' && inputs.operation == 'publish') ) runs-on: ubuntu-latest @@ -303,7 +308,7 @@ jobs: packages: write pull-requests: read env: - PREVIEW_TAG: pr-${{ github.event.pull_request.number || inputs.pr_number }} + PREVIEW_TAG: ${{ github.event_name == 'push' && 'v2' || format('pr-{0}', github.event.pull_request.number || inputs.pr_number) }} steps: - name: Log in to GHCR if: "!env.ACT" From 7e569bff4c161d3c0345f44c7c22b4918cb3820b Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 16 Aug 2026 22:10:12 +0200 Subject: [PATCH 02/12] fix: skip restore confirmation during onboarding --- .../src/components/backup/database-restore-flow.spec.ts | 6 +----- .../src/components/backup/database-restore-flow.tsx | 9 +++++---- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/apps/nextjs/src/components/backup/database-restore-flow.spec.ts b/apps/nextjs/src/components/backup/database-restore-flow.spec.ts index e52332e89..ab9114813 100644 --- a/apps/nextjs/src/components/backup/database-restore-flow.spec.ts +++ b/apps/nextjs/src/components/backup/database-restore-flow.spec.ts @@ -2,11 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { getStepAfterRestorePreview, waitForServerReadinessAsync } from "./database-restore-flow"; - -it("requires confirmation after every restore preview", () => { - expect(getStepAfterRestorePreview()).toBe("confirm"); -}); +import { waitForServerReadinessAsync } from "./database-restore-flow"; describe("waitForServerReadinessAsync", () => { beforeEach(() => { diff --git a/apps/nextjs/src/components/backup/database-restore-flow.tsx b/apps/nextjs/src/components/backup/database-restore-flow.tsx index eaa83301e..0708cd525 100644 --- a/apps/nextjs/src/components/backup/database-restore-flow.tsx +++ b/apps/nextjs/src/components/backup/database-restore-flow.tsx @@ -29,8 +29,6 @@ const RESTART_REQUEST_TIMEOUT_MS = 5_000; export type ServerReadinessResult = "ready" | "timedOut" | "aborted"; -export const getStepAfterRestorePreview = (): RestoreStep => "confirm"; - interface WaitForServerReadinessOptions { restartAfterMs: number; signal: AbortSignal; @@ -291,8 +289,11 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat <> - From 199f6ffc3b6743510aba6ddb8f423432fa9d4021 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 16 Aug 2026 22:25:15 +0200 Subject: [PATCH 03/12] fix: run custom migrations on backup restore --- apps/nextjs/src/app/api/backup/import/route.ts | 16 ++++++++++------ packages/db/package.json | 1 + 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/nextjs/src/app/api/backup/import/route.ts b/apps/nextjs/src/app/api/backup/import/route.ts index f7fc87471..db9b15f22 100644 --- a/apps/nextjs/src/app/api/backup/import/route.ts +++ b/apps/nextjs/src/app/api/backup/import/route.ts @@ -4,7 +4,7 @@ import path from "path"; import { NextResponse } from "next/server"; import AdmZip from "adm-zip"; -import Database from "better-sqlite3"; +import BetterSqlite3 from "better-sqlite3"; import { drizzle } from "drizzle-orm/better-sqlite3"; import { migrate } from "drizzle-orm/better-sqlite3/migrator"; @@ -18,6 +18,9 @@ import { env } from "@homarr/common/env"; import { DB_CASING } from "@homarr/core/infrastructure/db/constants"; import { dbEnv } from "@homarr/core/infrastructure/db/env"; import { db } from "@homarr/db"; +import type { Database } from "@homarr/db"; +import { applyCustomMigrationsAsync } from "@homarr/db/migrations/custom"; +import { schema } from "@homarr/db/schema"; import { findMigrationsFolder } from "../shared"; @@ -120,7 +123,7 @@ const assertArchiveSizeLimits = (zip: AdmZip) => { } }; -const reEncryptSecrets = (tempDb: InstanceType, importedKeyHex: string) => { +const reEncryptSecrets = (tempDb: InstanceType, importedKeyHex: string) => { const currentKeyHex = env.SECRET_ENCRYPTION_KEY; if (importedKeyHex === currentKeyHex) return; @@ -172,7 +175,7 @@ const reEncryptSecrets = (tempDb: InstanceType, importedKeyHex: transaction(); }; -const getHomeBoardName = (tempDb: InstanceType): string | null => { +const getHomeBoardName = (tempDb: InstanceType): string | null => { const row = tempDb .prepare( `SELECT "name" FROM ( @@ -234,7 +237,7 @@ export async function POST(req: Request) { restoreInProgress = true; let tempDirectory: string | null = null; - let tempDb: InstanceType | null = null; + let tempDb: InstanceType | null = null; try { let formData: FormData; @@ -285,9 +288,10 @@ export async function POST(req: Request) { } fs.writeFileSync(tempPath, dbEntry.getData()); - tempDb = new Database(tempPath); - const drizzleDb = drizzle(tempDb, { casing: DB_CASING }); + tempDb = new BetterSqlite3(tempPath); + const drizzleDb = drizzle(tempDb, { casing: DB_CASING, schema }); migrate(drizzleDb, { migrationsFolder }); + await applyCustomMigrationsAsync(drizzleDb as unknown as Database); const metadataEntry = zip.getEntry("metadata.json"); if (!metadataEntry) { diff --git a/packages/db/package.json b/packages/db/package.json index 01d0d8810..eab91d238 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -9,6 +9,7 @@ "exports": { ".": "./index.ts", "./collection": "./collection.ts", + "./migrations/custom": "./migrations/custom/index.ts", "./migrations/seed": "./migrations/seed.ts", "./proxy-reader": "./proxy-reader.ts", "./schema": "./schema/index.ts", From 304ea089a21f131c9edd54dcba8cad84ce4e30fc Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 16 Aug 2026 22:32:05 +0200 Subject: [PATCH 04/12] fix: resolve backup migrations in development --- apps/nextjs/src/app/api/backup/shared.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/nextjs/src/app/api/backup/shared.ts b/apps/nextjs/src/app/api/backup/shared.ts index 4b00a4bb3..276ce1a8b 100644 --- a/apps/nextjs/src/app/api/backup/shared.ts +++ b/apps/nextjs/src/app/api/backup/shared.ts @@ -1,12 +1,11 @@ import fs from "fs"; import path from "path"; -export const findMigrationsFolder = () => { - const cwd = process.cwd(); +export const findMigrationsFolder = (cwd = process.cwd()) => { const migrationsFolderCandidates = [ path.join(cwd, "db/migrations/sqlite"), path.join(cwd, "packages/db/migrations/sqlite"), - path.resolve(cwd, "../../db/migrations/sqlite"), + path.resolve(cwd, "../../packages/db/migrations/sqlite"), "/app/db/migrations/sqlite", ]; return migrationsFolderCandidates.find((candidate) => fs.existsSync(candidate)); From 960551f5730bb86c7cf028e28360b886c74d3245 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 16 Aug 2026 23:11:52 +0200 Subject: [PATCH 05/12] feat: simplify Workshop widget installation --- .../components/workshop/workshop-browser.tsx | 430 +++++++++++------- .../workshop/workshop-install-button.tsx | 17 +- .../src/workbench/import-review.tsx | 155 ++++--- packages/translation/src/lang/en.json | 4 + 4 files changed, 384 insertions(+), 222 deletions(-) diff --git a/apps/nextjs/src/components/workshop/workshop-browser.tsx b/apps/nextjs/src/components/workshop/workshop-browser.tsx index 89d069b30..e364f3cf8 100644 --- a/apps/nextjs/src/components/workshop/workshop-browser.tsx +++ b/apps/nextjs/src/components/workshop/workshop-browser.tsx @@ -2,6 +2,7 @@ import { useEffect, useId, useMemo, useState } from "react"; import { + Accordion, Alert, Badge, Box, @@ -21,16 +22,21 @@ import { Textarea, TextInput, Title, + VisuallyHidden, } from "@mantine/core"; import type { ModalProps } from "@mantine/core"; import { useDebouncedValue, useDisclosure } from "@mantine/hooks"; import { IconAlertTriangle, + IconArrowBigDown, + IconArrowBigUp, IconArrowRight, IconBuildingStore, IconDownload, + IconEye, IconExternalLink, IconFlag, + IconMessageCircle, IconRefresh, IconSearch, IconThumbDown, @@ -62,7 +68,7 @@ import { workshopExportFilename, } from "@homarr/workshop/schema"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; -import { useScopedI18n } from "@homarr/translation/client"; +import { useCurrentLocale, useScopedI18n } from "@homarr/translation/client"; import { createWorkshopClient, getWorkshopWebUrl } from "./workshop-client"; @@ -95,9 +101,27 @@ interface WorkshopModalStep { close(): void; } +const detailsModalStyles = { + content: { + display: "flex", + flexDirection: "column" as const, + height: "min(85dvh, 800px)", + }, + body: { + display: "flex", + flex: 1, + flexDirection: "column" as const, + minHeight: 0, + overflow: "hidden", + padding: 0, + }, +}; + export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, modalStack }: WorkshopBrowserProps) { const t = useScopedI18n("workshop"); + const currentLocale = useCurrentLocale(); const client = useMemo(createWorkshopClient, []); + const dateFormatter = useMemo(() => new Intl.DateTimeFormat(currentLocale, { dateStyle: "medium" }), [currentLocale]); const [user, setUser] = useState(null); const [page, setPage] = useState(1); const [sort, setSort] = useState<"top" | "newest">("top"); @@ -276,45 +300,86 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, mo ) : list.data?.items.length ? ( {list.data.items.map((item) => ( - + {item.screenshots[0] ? ( - + {t("screenshotAlt", ) : ( - {item.type === "customCss" ? "CSS" : "JSX"} + {item.type === "customCss" ? "CSS" : "JSX"} )} - - - - {t("author", { name: item.authorName || t("communityMember") })} - - - {item.upvotes} · {item.downvotes} + + + + {item.title} + + + {t("voteSummary", { upvotes: item.upvotes, downvotes: item.downvotes })} + + + + {item.score} + + + + + {t("author", { name: item.authorName || t("communityMember") })} · v{item.revision} ·{" "} + {dateFormatter.format(new Date(item.updated))} + {item.outdated && {t("outdated")}} {item.reportCount > 0 && {t("reportCount", { count: item.reportCount })}} - - {item.title} - - + {item.description || t("noDescription")} - + + + + + {t("comments", { count: item.commentCount })} + + + + ))} @@ -334,161 +399,198 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, mo stackId={modalStack?.details.stackId} title={type === "customCss" ? t("inspectCss") : t("install")} size={modalStack?.modalProps.size ?? "xl"} + styles={detailsModalStyles} > {detail.isPending ? ( - + + + ) : detail.isError || !detail.data ? ( - {detail.error instanceof Error ? detail.error.message : t("loadError")} + + {detail.error instanceof Error ? detail.error.message : t("loadError")} + ) : ( - - - - {detail.data.title} - - {t("author", { name: detail.data.authorName || t("communityMember") })} - - - - - {detail.data.description && {detail.data.description}} - {detail.data.outdated && ( - }> - {t("outdatedWarning")} - - )} - {detail.data.reportCount > 0 && ( - - }> - {t("reportWarning", { count: detail.data.reportCount })} - - {reportSummaries.data?.map((summary) => ( - - - {t(`reportCategory.${summary.category}`)} - - - {summary.explanation} + <> + + + + + {detail.data.title} + + {t("author", { name: detail.data.authorName || t("communityMember") })} - - ))} - {reportSummaries.data?.length === 0 && ( + + + + {detail.data.description && {detail.data.description}} + {detail.data.outdated && ( + }> + {t("outdatedWarning")} + + )} + {detail.data.reportCount > 0 && ( + + }> + {t("reportWarning", { count: detail.data.reportCount })} + + {reportSummaries.data?.map((summary) => ( + + + {t(`reportCategory.${summary.category}`)} + + + {summary.explanation} + + + ))} + {reportSummaries.data?.length === 0 && ( + + {t("reportVisibility")} + + )} + + )} + {detail.data.screenshots.length > 0 && ( + + {detail.data.screenshots.map((file, index) => ( + {t("screenshotAlt", + ))} + + )} + {detailValidation && !detailValidation.success && ( + }> + {t("installErrorDescription")} {detailValidation.error} + + )} + {detailValidation?.success && !detailCompatible && ( + }> + {t("installErrorDescription")} + + )} + + + {t("technicalDetails")} + + + {type === "customWidget" && + detailValidation?.success && + typeof detailValidation.data !== "string" && ( + + )} + + + + + + {!user && ( - {t("reportVisibility")} + {t("signInHint")} )} + {vote.error && {vote.error.message || t("voteError")}} + {install.error && {install.error.message}} + {useCss.error && {useCss.error.message}} - )} - {detail.data.screenshots.length > 0 && ( - - {detail.data.screenshots.map((file, index) => ( - {t("screenshotAlt", - ))} - - )} - {type === "customWidget" && detailValidation?.success && typeof detailValidation.data !== "string" && ( - - )} - {detailValidation && !detailValidation.success && ( - }> - {t("installErrorDescription")} {detailValidation.error} - - )} - {detailValidation?.success && !detailCompatible && ( - }> - {t("installErrorDescription")} - - )} - - - - - - - + + + + + + + + + + {onInstall && ( + + )} + {onUseCss && ( + + )} - {onInstall && ( - - )} - {onUseCss && ( - - )} - - {!user && ( - - {t("signInHint")} - - )} - {vote.error && {vote.error.message || t("voteError")}} - {install.error && {install.error.message}} - {useCss.error && {useCss.error.message}} - + + )} diff --git a/apps/nextjs/src/components/workshop/workshop-install-button.tsx b/apps/nextjs/src/components/workshop/workshop-install-button.tsx index 2f00b14f8..b6ac5136a 100644 --- a/apps/nextjs/src/components/workshop/workshop-install-button.tsx +++ b/apps/nextjs/src/components/workshop/workshop-install-button.tsx @@ -25,6 +25,21 @@ const installFlowModalProps = { }, } satisfies Pick; +const installReviewModalProps = { + size: "90%", + styles: { + content: { display: "flex", flexDirection: "column", height: "min(85dvh, 900px)" }, + body: { + display: "flex", + flex: 1, + flexDirection: "column", + minHeight: 0, + overflow: "hidden", + padding: 0, + }, + }, +} satisfies Pick; + export function WorkshopInstallButton({ children, fullWidth }: WorkshopInstallButtonProps = {}) { const t = useScopedI18n("workshop"); const stack = useModalsStack(["workshop", "details", "report", "review"]); @@ -74,7 +89,7 @@ export function WorkshopInstallButton({ children, fullWidth }: WorkshopInstallBu @@ -70,65 +98,78 @@ export function ImportReviewDialog({ stackId={stackId} zIndex={zIndex} size={size ?? "lg"} - styles={styles} + styles={styles ?? importReviewModalStyles} > {review && ( - - - {messages.description} - - - - - - - -
- - {messages.methods} - - - {review.methods.map((method) => ( - - {method} - - ))} - -
-
- - {messages.permissions} - - - {review.permissions.map((permission) => ( - - {messages.permission(permission)} - - ))} - -
- {review.hasActions && ( - }> - - {messages.actionWarningTitle} + <> + + + + {messages.description} - {messages.actionWarningDescription} - - )} - {children} - - - - -
+ + + + + + + +
+ + {messages.methods} + + + {review.methods.map((method) => ( + + {method} + + ))} + +
+
+ + {messages.permissions} + + + {review.permissions.map((permission) => ( + + {messages.permission(permission)} + + ))} + +
+
+ {review.hasActions && ( + }> + + {messages.actionWarningTitle} + + {messages.actionWarningDescription} + + )} + {children} + + + + + + + + + )} ); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 6943988f1..8a57998e9 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1407,6 +1407,9 @@ "noDescription": "No description", "author": "by {name}", "communityMember": "Community member", + "comments": "{count, plural, one {# comment} other {# comments}}", + "viewDetails": "View details", + "voteSummary": "{upvotes, plural, one {# upvote} other {# upvotes}}, {downvotes, plural, one {# downvote} other {# downvotes}}", "outdated": "Outdated", "outdatedWarning": "The author marked this submission as outdated. Review it carefully before importing.", "reportCount": "{count, plural, one {# report} other {# reports}}", @@ -1416,6 +1419,7 @@ "upvote": "Upvote, {count, plural, one {# vote} other {# votes}}", "downvote": "Downvote, {count, plural, one {# vote} other {# votes}}", "inspectCss": "Inspect CSS", + "technicalDetails": "Technical details", "sourceCode": "Workshop entry", "loadError": "This submission could not be loaded.", "sources": "Declared API sources", From 4e514ab15178e531b1d5b089f6bddbad7d327cfe Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 16 Aug 2026 23:15:09 +0200 Subject: [PATCH 06/12] fix: show Workshop voting as net score --- .../components/workshop/workshop-browser.tsx | 55 +++++++++++-------- packages/translation/src/lang/en.json | 1 + 2 files changed, 33 insertions(+), 23 deletions(-) diff --git a/apps/nextjs/src/components/workshop/workshop-browser.tsx b/apps/nextjs/src/components/workshop/workshop-browser.tsx index e364f3cf8..f31c99ec8 100644 --- a/apps/nextjs/src/components/workshop/workshop-browser.tsx +++ b/apps/nextjs/src/components/workshop/workshop-browser.tsx @@ -3,6 +3,7 @@ import { useEffect, useId, useMemo, useState } from "react"; import { Accordion, + ActionIcon, Alert, Badge, Box, @@ -39,8 +40,6 @@ import { IconMessageCircle, IconRefresh, IconSearch, - IconThumbDown, - IconThumbUp, } from "@tabler/icons-react"; import { CUSTOM_WIDGET_SCHEMA } from "@homarr/custom-widgets/core"; import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core"; @@ -526,28 +525,38 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, mo > {t("export")} - - + vote.mutate({ submission: detail.data.id, value: 1 })} + > + + + + {detail.data.score} + + vote.mutate({ submission: detail.data.id, value: -1 })} + > + + + )} - - + } + /> + ); +} + +/** Everything that decides whether this widget can actually run right now. */ +function CustomWidgetStatusBadges({ widget }: { widget: WidgetDef }) { + const t = useScopedI18n("customWidget"); + + if (widget.migrationRequired) { + return ( + + }> + {t("page.list.migrationRequired")} + + + ); + } + + if (!widget.valid) { + return ( + (issue.path ? `${issue.path}: ${issue.message}` : issue.message)) + .join("\n")} + multiline + maw={520} + > + }> + {t("page.list.invalidDefinition")} + + + ); + } + + return ( + + {widget.enabled ? ( + + {t("page.list.enabled")} + + ) : ( + + + {t("page.list.disabled")} + + + )} + {widget.missingSecrets.length > 0 && ( + + }> + {t("page.list.missingCredentials", { count: widget.missingSecrets.length })} + + + )} + ); } diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-tabs.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-tabs.tsx new file mode 100644 index 000000000..1819d915f --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-tabs.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { Tabs } from "@mantine/core"; +import { IconApi, IconBuildingStore } from "@tabler/icons-react"; + +import { useScopedI18n } from "@homarr/translation/client"; +import { Link } from "@homarr/ui"; + +const tabs = { + installed: "/manage/custom-widgets", + workshop: "/manage/custom-widgets/workshop", +} as const; + +/** Switches between the widgets you have installed and the community Workshop. */ +export const CustomWidgetTabs = ({ active }: { active: keyof typeof tabs }) => { + const t = useScopedI18n("customWidget.page.tabs"); + + return ( + + + } + renderRoot={(props) => } + > + {t("installed")} + + } + renderRoot={(props) => } + > + {t("workshop")} + + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/page.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/page.tsx index 21541929f..b0798e9f9 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/page.tsx @@ -10,9 +10,9 @@ import { Link } from "@homarr/ui"; import { ManagePageLayout } from "~/components/manage/manage-page-layout"; import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; import { CustomWidgetBetaBanner } from "./_beta-banner"; -import { WorkshopInstallButton } from "~/components/workshop/workshop-install-button"; -import { ImportCustomWidgetButton } from "./_import-custom-widget-button"; import { CustomWidgetList } from "./_custom-widget-list"; +import { CustomWidgetTabs } from "./_custom-widget-tabs"; +import { ImportCustomWidgetButton } from "./_import-custom-widget-button"; export default async function CustomWidgetsPage() { const session = await auth(); @@ -38,13 +38,13 @@ export default async function CustomWidgetsPage() { > {t("action.downloadSkill")} - }> {t("action.create")} } + toolbar={} > diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/publish/[id]/_workshop-publish-form.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/publish/[id]/_workshop-publish-form.tsx new file mode 100644 index 000000000..d0e174176 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/publish/[id]/_workshop-publish-form.tsx @@ -0,0 +1,248 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + Alert, + Box, + Button, + Checkbox, + FileInput, + Group, + Paper, + Stack, + Text, + Textarea, + TextInput, + ThemeIcon, + Title, +} from "@mantine/core"; +import { + IconArrowLeft, + IconBuildingStore, + IconCheck, + IconExternalLink, + IconPhoto, + IconShieldCheck, +} from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { useScopedI18n } from "@homarr/translation/client"; +import { Link } from "@homarr/ui"; +import { useWorkshopCreateMutation } from "@homarr/workshop/backend"; +import { workshopScreenshotsSchema } from "@homarr/workshop/schema"; + +import { ManagePageLayout } from "~/components/manage/manage-page-layout"; +import { ManageStickyFooter } from "~/components/manage/manage-sticky-footer"; +import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; +import { getWorkshopWebUrl } from "~/components/workshop/workshop-client"; +import { + getPrivateWorkshopSourceNames, + publishWorkshopDefinition, + serializeWorkshopDefinition, +} from "~/components/workshop/workshop-publish-definition"; +import { WorkshopAccountButton, useWorkshopSession } from "~/components/workshop/workshop-session"; + +const listHref = "/manage/custom-widgets"; + +export function WorkshopPublishForm({ widget }: { widget: { id: string; name: string } }) { + const t = useScopedI18n("workshop"); + const session = useWorkshopSession(); + const [title, setTitle] = useState(widget.name); + const [description, setDescription] = useState(""); + const [screenshots, setScreenshots] = useState([]); + const [sourceUrlsReviewed, setSourceUrlsReviewed] = useState(false); + const [error, setError] = useState(null); + const [publishedSubmissionId, setPublishedSubmissionId] = useState(null); + + const createSubmission = useWorkshopCreateMutation(session.client); + const definition = clientApi.customWidget.export.useQuery({ id: widget.id }); + const privateSourceNames = getPrivateWorkshopSourceNames(definition.data); + const definitionFingerprint = definition.data ? serializeWorkshopDefinition(definition.data) : null; + useEffect(() => setSourceUrlsReviewed(false), [definitionFingerprint]); + + const breadcrumb = ; + + const publish = async () => { + // Publishing is immediate and public; a second in-flight call would create a + // duplicate listing. + if (createSubmission.isPending) return; + setError(null); + try { + if (!definition.data) throw new Error(t("publish.error")); + if (!workshopScreenshotsSchema.safeParse(screenshots).success) throw new Error(t("publish.invalidScreenshot")); + + let submissionId: string | null = null; + const result = await publishWorkshopDefinition({ + inspectedDefinition: definition.data, + refetchDefinition: async () => (await definition.refetch()).data, + publish: async (content) => { + const submission = await createSubmission.mutateAsync({ + input: { type: "customWidget", title, description, content }, + screenshots, + }); + submissionId = submission.id; + }, + }); + if (result === "unavailable") throw new Error(t("publish.error")); + if (result === "changed") { + setSourceUrlsReviewed(false); + setError(t("publish.definitionChanged")); + return; + } + if (!submissionId) throw new Error(t("publish.error")); + setPublishedSubmissionId(submissionId); + } catch (cause) { + setError(cause instanceof Error ? cause.message : t("publish.error")); + } + }; + + if (publishedSubmissionId) { + return ( + + + + + + + + {t("publish.success")} + + + {t("publish.manageDescription")} + + + + + + + + + ); + } + + const blocked = + !session.user || + createSubmission.isPending || + !definition.data || + definition.isError || + title.trim().length < 3 || + (privateSourceNames.length > 0 && !sourceUrlsReviewed); + + return ( + + {t("publish.title")} + + {widget.name} + + + } + breadcrumb={breadcrumb} + primaryAction={} + > + + + + + + + + {t("publish.introTitle")} + + {t("publish.introDescription")} + + + + + + {!session.user && {t("publish.signInHint")}} + + + + + + {t("publish.listingDetails")} + + + {t("publish.listingDetailsDescription")} + + + setTitle(event.currentTarget.value)} + required + /> +