feat: add community marketplace for custom widgets and CSS
Adds a PocketBase-backed marketplace where users can share custom widgets and CSS themes. Includes a Docusaurus storefront with GitHub OAuth login, submission/voting/comments, and a PocketBase SQL view that computes author names, vote counts, and comment counts without fragile hooks. The Next.js app gets a modal browser for one-click install from the marketplace plus clipboard paste-to-import on the custom widgets pages.
This commit is contained in:
44 files changed
+5897
-1643
No files matched your search
@@ -11,6 +11,7 @@ import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import { useForm } from "@homarr/form";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { InstallCssFromStoreButton } from "~/components/store-install/install-from-store-buttons";
|
||||
import type { Board } from "../../_types";
|
||||
import { useSavePartialSettingsMutation } from "./_shared";
|
||||
import classes from "./customcss.module.css";
|
||||
@@ -45,7 +46,8 @@ export const CustomCssSettingsContent = ({ board }: Props) => {
|
||||
{customCssT("customClassesAlert.description")}
|
||||
</Alert>
|
||||
|
||||
<Group justify="end">
|
||||
<Group justify="space-between">
|
||||
<InstallCssFromStoreButton onSelect={(css) => form.setFieldValue("customCss", css)} />
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Popover, Stack, Text } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconCopy, IconSparkles } from "@tabler/icons-react";
|
||||
@@ -296,8 +297,11 @@ interface CopyAiPromptButtonProps {
|
||||
export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptButtonProps) => {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { data: schema, isLoading } = clientApi.customWidget.schema.useQuery();
|
||||
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!schema) return;
|
||||
const prompt = buildAiPrompt(schema, rawResponse, currentConfig);
|
||||
@@ -317,8 +321,8 @@ export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptB
|
||||
variant="light"
|
||||
leftSection={<IconSparkles size={16} />}
|
||||
onClick={open}
|
||||
loading={isLoading}
|
||||
disabled={!schema}
|
||||
loading={!mounted || isLoading}
|
||||
disabled={!mounted || isLoading || !schema}
|
||||
fullWidth
|
||||
size="sm"
|
||||
>
|
||||
|
||||
@@ -30,9 +30,11 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type {
|
||||
CustomWidgetAuthType,
|
||||
CustomWidgetDisplayType,
|
||||
CustomWidgetImport,
|
||||
CustomWidgetMethod,
|
||||
CustomWidgetSecretKind,
|
||||
} from "@homarr/validation/custom-widget";
|
||||
import { customWidgetImportSchema } from "@homarr/validation/custom-widget";
|
||||
import type { displayConfigSchema } from "@homarr/validation/custom-widget";
|
||||
import { JsonPathTreePicker } from "@homarr/widgets/_inputs/json-path-tree-picker";
|
||||
|
||||
@@ -467,6 +469,36 @@ const serverToFormFieldMap: Record<string, Record<string, string>> = {
|
||||
customJsx: { template: "template" },
|
||||
};
|
||||
|
||||
const importToFormValues: Record<string, (config: Record<string, unknown>) => Record<string, unknown>> = {
|
||||
singleValue: (c) => ({ jsonPath: c.jsonPath, label: c.label ?? "", unit: c.unit ?? "", valueSize: c.valueSize ?? "lg", labelPosition: c.labelPosition ?? "below" }),
|
||||
keyValue: (c) => ({ mappings: (c.mappings as Array<Record<string, string>>)?.map((m) => ({ label: m.label, jsonPath: m.jsonPath, unit: m.unit ?? "" })) ?? [], kvLayout: c.layout ?? "list", kvColumns: c.columns ?? 2 }),
|
||||
table: (c) => ({ tablePath: c.tablePath, columns: c.columns ?? [], striped: c.striped ?? true, compact: c.compact ?? false }),
|
||||
statGrid: (c) => ({ statGridItems: c.items ?? [], statGridColumns: c.columns ?? 2, cardStyle: c.cardStyle ?? "filled" }),
|
||||
progressBars: (c) => ({ progressBars: (c.bars as Array<Record<string, unknown>>)?.map((b) => ({ label: b.label ?? "", valuePath: b.valuePath ?? "", maxPath: b.maxPath ?? "", unit: b.unit ?? "", color: b.color ?? "blue" })) ?? [], showPercentage: c.showPercentage ?? true, barSize: c.barSize ?? "md" }),
|
||||
statusIndicator: (c) => ({ statusItems: (c.items as Array<Record<string, unknown>>)?.map((i) => ({ label: i.label ?? "", jsonPath: i.jsonPath ?? "", goodValues: Array.isArray(i.goodValues) ? (i.goodValues as string[]).join(", ") : String(i.goodValues ?? "") })) ?? [], statusLayout: c.layout ?? "list", dotSize: c.dotSize ?? "md" }),
|
||||
countGrid: (c) => ({ countGridItems: c.items ?? [], countGridColumns: c.columns ?? 2, countValueSize: c.valueSize ?? "md" }),
|
||||
raw: (c) => ({ rawJsonPath: c.jsonPath ?? "$", rawMaxHeight: c.maxHeight ?? 300 }),
|
||||
actionButton: (c) => ({ buttonLabel: c.buttonLabel ?? "", buttonColor: c.buttonColor ?? "blue", confirmText: c.confirmText ?? "", successMessage: c.successMessage ?? "" }),
|
||||
customJsx: (c) => ({ template: c.template ?? "" }),
|
||||
};
|
||||
|
||||
function importJsonToFormValues(imported: CustomWidgetImport): Partial<z.infer<typeof formSchema>> {
|
||||
const displayFields = importToFormValues[imported.displayType]?.(imported.displayConfig as unknown as Record<string, unknown>) ?? {};
|
||||
return {
|
||||
name: imported.name,
|
||||
description: imported.description ?? "",
|
||||
iconUrl: imported.iconUrl ?? "",
|
||||
url: imported.url,
|
||||
authType: imported.authType,
|
||||
headerName: imported.headerName ?? "",
|
||||
method: imported.method,
|
||||
requestBody: imported.requestBody ?? "",
|
||||
displayType: imported.displayType,
|
||||
...displayFields,
|
||||
secrets: [],
|
||||
};
|
||||
}
|
||||
|
||||
function extractServerErrors(err: unknown, displayType: string): Record<string, string> {
|
||||
const errors: Record<string, string> = {};
|
||||
const trpcErr = err as {
|
||||
@@ -681,6 +713,25 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
}
|
||||
}, [getPreviewInput, previewMutation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "create") return;
|
||||
const onPaste = (e: ClipboardEvent) => {
|
||||
const raw = e.clipboardData?.getData("text/plain");
|
||||
if (!raw) return;
|
||||
let parsed: unknown;
|
||||
try { parsed = JSON.parse(raw); } catch { return; }
|
||||
const result = customWidgetImportSchema.safeParse(parsed);
|
||||
if (!result.success) return;
|
||||
e.preventDefault();
|
||||
const formValues = importJsonToFormValues(result.data);
|
||||
form.setValues({ ...defaultCreateValues, ...formValues });
|
||||
form.clearErrors();
|
||||
showSuccessNotification({ title: t("action.paste"), message: t("notification.imported" as never, { name: result.data.name }) });
|
||||
};
|
||||
document.addEventListener("paste", onPaste);
|
||||
return () => document.removeEventListener("paste", onPaste);
|
||||
}, [form, t, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (previewRefreshSignal > 0 && hasTestedRef.current) {
|
||||
void handlePreviewTest();
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { ActionIcon, Avatar, Badge, Group, Stack, Table, Text } from "@mantine/core";
|
||||
import { IconApi, IconPencil } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
import { customWidgetImportSchema } from "@homarr/validation/custom-widget";
|
||||
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { CustomWidgetRowActions } from "./_custom-widget-actions";
|
||||
@@ -31,6 +36,32 @@ interface CustomWidgetListProps {
|
||||
|
||||
export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const utils = clientApi.useUtils();
|
||||
const importMutation = clientApi.customWidget.import.useMutation({
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({ title: t("action.paste"), message: "Custom widget imported" });
|
||||
void utils.customWidget.all.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
},
|
||||
onError: (err) => {
|
||||
showErrorNotification({ title: t("action.paste"), message: err.message || "Failed to import" });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const onPaste = (e: ClipboardEvent) => {
|
||||
const raw = e.clipboardData?.getData("text/plain");
|
||||
if (!raw) return;
|
||||
let parsed: unknown;
|
||||
try { parsed = JSON.parse(raw); } catch { return; }
|
||||
const result = customWidgetImportSchema.safeParse(parsed);
|
||||
if (!result.success) return;
|
||||
e.preventDefault();
|
||||
importMutation.mutate(result.data);
|
||||
};
|
||||
document.addEventListener("paste", onPaste);
|
||||
return () => document.removeEventListener("paste", onPaste);
|
||||
}, [importMutation]);
|
||||
|
||||
if (definitions.length === 0) {
|
||||
return (
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Link } from "@homarr/ui";
|
||||
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { InstallWidgetFromStoreButton } from "~/components/store-install/install-from-store-buttons";
|
||||
import { CustomWidgetBetaBanner } from "./_beta-banner";
|
||||
import { ImportCustomWidgetButton } from "./_custom-widget-actions";
|
||||
import { CustomWidgetList } from "./_custom-widget-list";
|
||||
@@ -28,6 +29,7 @@ export default async function CustomWidgetsPage() {
|
||||
title={t("page.list.title")}
|
||||
primaryAction={
|
||||
<Group gap="xs">
|
||||
<InstallWidgetFromStoreButton />
|
||||
<ImportCustomWidgetButton />
|
||||
<MobileAffixButton component={Link} href="/manage/custom-widgets/new" leftSection={<IconPlus size={16} />}>
|
||||
{t("action.create")}
|
||||
|
||||
Reference in new issue
Block a user