feat(custom-widgets): complete Custom JSX v2 (#6390)

This commit is contained in:
Thomas authored and ajnart committed 2026-08-13 19:48:57 +02:00
1 parent 3a1245cbc5
commit acdfcbb18e
416 files changed
+37853 -18066

No files matched your search

@@ -0,0 +1,523 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Badge,
Box,
Button,
Card,
Code,
Group,
Image,
Loader,
Modal,
Pagination,
Select,
SimpleGrid,
Skeleton,
Stack,
Text,
Textarea,
TextInput,
Title,
} from "@mantine/core";
import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
import {
IconAlertTriangle,
IconBuildingStore,
IconDownload,
IconFlag,
IconSearch,
IconThumbDown,
IconThumbUp,
} from "@tabler/icons-react";
import { CUSTOM_WIDGET_SCHEMA } from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import {
useWorkshopCssImportMutation,
useWorkshopQuery,
useWorkshopReportMutation,
useWorkshopSubmissionQuery,
useWorkshopVoteMutation,
useWorkshopWidgetImportMutation,
} from "@homarr/workshop/backend";
import type {
WorkshopReport,
WorkshopSubmissionDetail,
WorkshopSubmissionType,
WorkshopUser,
} from "@homarr/workshop/schema";
import {
validateWorkshopContent,
validateWorkshopWidget,
WORKSHOP_CSS_SCHEMA,
workshopExportFilename,
} from "@homarr/workshop/schema";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { createWorkshopClient } from "./workshop-client";
function downloadWorkshopSubmission(submission: WorkshopSubmissionDetail) {
const url = URL.createObjectURL(
new Blob([submission.content], { type: submission.type === "customCss" ? "text/css" : "application/json" }),
);
const link = document.createElement("a");
link.href = url;
link.download = workshopExportFilename(submission.title, submission.type);
link.click();
URL.revokeObjectURL(url);
}
interface WorkshopBrowserProps {
type?: WorkshopSubmissionType;
onInstall?(widget: HomarrCustomWidgetV2): Promise<void>;
onUseCss?(css: string): void;
}
export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }: WorkshopBrowserProps) {
const t = useScopedI18n("workshop");
const client = useMemo(createWorkshopClient, []);
const [user, setUser] = useState<WorkshopUser | null>(null);
const [page, setPage] = useState(1);
const [sort, setSort] = useState<"top" | "newest">("top");
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 250);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [reportOpened, reportControls] = useDisclosure(false);
const [reportCategory, setReportCategory] = useState<WorkshopReport["category"]>("other");
const [reportExplanation, setReportExplanation] = useState("");
const [loginPending, setLoginPending] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [cssAwaitingConfirmation, setCssAwaitingConfirmation] = useState<string | null>(null);
useEffect(() => {
const unsubscribe = client.subscribeToAuth(setUser);
void client.refreshAuth().then(setUser);
return unsubscribe;
}, [client]);
const list = useWorkshopQuery(client, { page, perPage: 12, type, sort, search: debouncedSearch });
const detail = useWorkshopSubmissionQuery(client, selectedId ?? "");
const detailValidation = useMemo(() => {
if (!detail.data) return null;
return type === "customCss"
? validateWorkshopContent("customCss", detail.data.content)
: validateWorkshopWidget(detail.data.content);
}, [detail.data, type]);
const expectedSchema = type === "customCss" ? WORKSHOP_CSS_SCHEMA : CUSTOM_WIDGET_SCHEMA;
const detailCompatible =
detail.data?.type === type && detailValidation?.success === true && detail.data.widgetSchema === expectedSchema;
const signIn = () => {
setLoginPending(true);
setLoginError(null);
void client
.signInWithGitHub()
.then(setUser)
.catch((cause: unknown) => {
const message = cause instanceof Error ? cause.message : t("signInError");
setLoginError(message);
showErrorNotification({ title: t("signIn"), message });
})
.finally(() => setLoginPending(false));
};
const install = useWorkshopWidgetImportMutation(onInstall, () => setSelectedId(null));
const useCss = useWorkshopCssImportMutation(onUseCss, () => {
setCssAwaitingConfirmation(null);
setSelectedId(null);
});
const vote = useWorkshopVoteMutation(client);
const report = useWorkshopReportMutation(client);
const submitReport = () =>
report.mutate(
{ submission: selectedId ?? "", category: reportCategory, explanation: reportExplanation },
{
onSuccess: () => {
reportControls.close();
setReportExplanation("");
showSuccessNotification({ title: t("reportSent"), message: t("reportSentDescription") });
},
},
);
return (
<Stack gap="lg">
<Card withBorder radius="lg" p="lg">
<Group justify="space-between" align="flex-start">
<Group align="flex-start">
<Box
p="sm"
bg="var(--mantine-color-default-hover)"
style={{ borderRadius: "var(--mantine-radius-md)", lineHeight: 0 }}
>
<IconBuildingStore size={24} />
</Box>
<Box>
<Title order={2}>{t("title")}</Title>
<Text c="dimmed" size="sm" maw={640}>
{type === "customCss" ? t("descriptionCss") : t("description")}
</Text>
</Box>
</Group>
{user ? (
<Group gap="xs">
<Badge variant="light">{user.displayName}</Badge>
<Button
size="xs"
variant="subtle"
onClick={() => {
client.signOut();
setUser(null);
}}
>
{t("signOut")}
</Button>
</Group>
) : (
<Button size="xs" loading={loginPending} onClick={signIn}>
{t("signIn")}
</Button>
)}
</Group>
</Card>
<Alert color="yellow" icon={<IconAlertTriangle size={18} />}>
{type === "customCss" ? t("securityNoticeCss") : t("securityNotice")}
</Alert>
{loginError && <Alert color="red">{loginError}</Alert>}
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<TextInput
label={type === "customCss" ? t("searchCss") : t("search")}
leftSection={<IconSearch size={16} />}
rightSection={list.isFetching ? <Loader size={16} /> : undefined}
value={search}
onChange={(event) => {
setSearch(event.currentTarget.value);
setPage(1);
}}
/>
<Select
label={t("sort")}
value={sort}
onChange={(value) => {
setSort(value === "newest" ? "newest" : "top");
setPage(1);
}}
data={[
{ value: "top", label: t("sortTop") },
{ value: "newest", label: t("sortNewest") },
]}
allowDeselect={false}
/>
</SimpleGrid>
{list.isLoading ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
<Skeleton h={280} />
<Skeleton h={280} />
<Skeleton h={280} />
</SimpleGrid>
) : list.isError ? (
<Alert color="red">{list.error instanceof Error ? list.error.message : t("unavailable")}</Alert>
) : list.data?.items.length ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
{list.data.items.map((item) => (
<Card key={item.id} withBorder radius="md" p="md">
{item.screenshots[0] ? (
<Card.Section>
<Image src={client.fileUrl(item.id, item.screenshots[0], "480x320")} h={170} alt="" />
</Card.Section>
) : (
<Card.Section>
<Box
h={170}
bg="var(--mantine-color-default-hover)"
style={{ display: "grid", placeItems: "center" }}
>
<Code>{item.type === "customCss" ? "CSS" : "JSX"}</Code>
</Box>
</Card.Section>
)}
<Stack gap="xs" mt="md">
<Group justify="space-between">
<Text size="xs" c="dimmed">
{t("author", { name: item.authorName })}
</Text>
<Text size="sm" c="dimmed">
<IconThumbUp size={13} /> {item.upvotes} · <IconThumbDown size={13} /> {item.downvotes}
</Text>
</Group>
<Badge color={item.widgetSchema === expectedSchema ? "green" : "red"} variant="light">
{item.widgetSchema}
</Badge>
<Group gap="xs">
{item.outdated && <Badge color="yellow">Outdated</Badge>}
{item.reportCount > 0 && (
<Badge color="red">
{item.reportCount} report{item.reportCount === 1 ? "" : "s"}
</Badge>
)}
</Group>
<Text fw={700} lineClamp={1}>
{item.title}
</Text>
<Text size="sm" c="dimmed" lineClamp={2}>
{item.description || t("noDescription")}
</Text>
<Button variant="light" onClick={() => setSelectedId(item.id)}>
{type === "customCss" ? t("inspectCss") : t("inspect")}
</Button>
</Stack>
</Card>
))}
</SimpleGrid>
) : (
<Text c="dimmed" ta="center" py="xl">
{type === "customCss" ? t("emptyCss") : t("empty")}
</Text>
)}
{(list.data?.totalPages ?? 0) > 1 && (
<Pagination total={list.data?.totalPages ?? 1} value={page} onChange={setPage} mx="auto" />
)}
<Modal
opened={selectedId !== null}
onClose={() => setSelectedId(null)}
title={detail.data?.title ?? t("details")}
size="xl"
>
{detail.isPending ? (
<Skeleton h={400} />
) : detail.isError || !detail.data ? (
<Alert color="red">{detail.error instanceof Error ? detail.error.message : t("loadError")}</Alert>
) : (
<Stack>
<Text>{detail.data.description}</Text>
<Text size="sm" c="dimmed">
{t("author", { name: detail.data.authorName })}
</Text>
{detail.data.outdated && (
<Alert color="yellow" icon={<IconAlertTriangle size={18} />}>
The author marked this submission as outdated. Review it carefully before importing.
</Alert>
)}
{detail.data.reportCount > 0 && (
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
This submission has {detail.data.reportCount} open community report
{detail.data.reportCount === 1 ? "" : "s"}. It remains installable, but you should review its content
first.
</Alert>
)}
{detail.data.screenshots.length > 0 && (
<SimpleGrid cols={{ base: 1, sm: 2 }}>
{detail.data.screenshots.map((file, index) => (
<Image
key={file}
src={client.fileUrl(detail.data.id, file, "960x640")}
radius="md"
alt={`${detail.data.title} screenshot ${index + 1}`}
/>
))}
</SimpleGrid>
)}
{type === "customWidget" && detailValidation?.success && typeof detailValidation.data !== "string" && (
<CapabilitySummary widget={detailValidation.data} />
)}
{detailValidation && !detailValidation.success && (
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
{t("installErrorDescription")} {detailValidation.error}
</Alert>
)}
{detailValidation?.success && !detailCompatible && (
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
{t("installErrorDescription")}
</Alert>
)}
<Code block mah={360} style={{ overflow: "auto", whiteSpace: "pre" }}>
{detail.data.content}
</Code>
<Group justify="space-between">
<Group gap="xs">
<Button
variant="default"
leftSection={<IconDownload size={16} />}
onClick={() => downloadWorkshopSubmission(detail.data)}
>
{t("export")}
</Button>
<Button
variant="subtle"
leftSection={<IconThumbUp size={16} />}
loading={vote.isPending && vote.variables?.value === 1}
disabled={!user || vote.isPending}
onClick={() => vote.mutate({ submission: detail.data.id, value: 1 })}
>
{detail.data.upvotes}
</Button>
<Button
variant="subtle"
leftSection={<IconThumbDown size={16} />}
loading={vote.isPending && vote.variables?.value === -1}
disabled={!user || vote.isPending}
onClick={() => vote.mutate({ submission: detail.data.id, value: -1 })}
>
{detail.data.downvotes}
</Button>
<Button
variant="subtle"
color="red"
leftSection={<IconFlag size={16} />}
disabled={!user}
onClick={reportControls.open}
>
{t("report")}
</Button>
</Group>
{onInstall && (
<Button
loading={install.isPending}
disabled={!detailCompatible}
onClick={() =>
detailValidation?.success &&
typeof detailValidation.data !== "string" &&
install.mutate(detailValidation.data)
}
>
{t("install")}
</Button>
)}
{onUseCss && (
<Button
disabled={!detailCompatible}
onClick={() =>
detailValidation?.success &&
typeof detailValidation.data === "string" &&
setCssAwaitingConfirmation(detailValidation.data)
}
>
{t("useCss")}
</Button>
)}
</Group>
{!user && (
<Text size="xs" c="dimmed">
{t("signInHint")}
</Text>
)}
{vote.error && <Alert color="red">{vote.error.message || t("voteError")}</Alert>}
{install.error && <Alert color="red">{install.error.message}</Alert>}
{useCss.error && <Alert color="red">{useCss.error.message}</Alert>}
</Stack>
)}
</Modal>
<Modal opened={reportOpened} onClose={reportControls.close} title={t("reportTitle")}>
<Stack>
<Select
label={t("reportReason")}
value={reportCategory}
onChange={(value) => setReportCategory((value as WorkshopReport["category"]) ?? "other")}
data={["malicious", "spam", "copyright", "inappropriate", "other"]}
allowDeselect={false}
/>
<Textarea
label={t("reportExplanation")}
minRows={4}
value={reportExplanation}
onChange={(event) => setReportExplanation(event.currentTarget.value)}
/>
{report.error && <Alert color="red">{report.error.message}</Alert>}
<Button loading={report.isPending} disabled={reportExplanation.trim().length < 3} onClick={submitReport}>
{t("reportSend")}
</Button>
</Stack>
</Modal>
<Modal
opened={cssAwaitingConfirmation !== null}
onClose={() => setCssAwaitingConfirmation(null)}
title="Import this Custom CSS?"
>
<Stack>
<Alert color="yellow" icon={<IconAlertTriangle size={18} />}>
Review community CSS before applying it. Importing replaces the current Custom CSS editor value, but nothing
is saved until you save the board settings.
</Alert>
<Group justify="flex-end">
<Button variant="default" onClick={() => setCssAwaitingConfirmation(null)}>
Cancel
</Button>
<Button
loading={useCss.isPending}
onClick={() => {
if (cssAwaitingConfirmation !== null) useCss.mutate(cssAwaitingConfirmation);
}}
>
Import CSS
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}
function CapabilitySummary({ widget }: { widget: HomarrCustomWidgetV2 }) {
const t = useScopedI18n("workshop");
return (
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<Card withBorder p="sm">
<Text fw={600} size="sm" mb="xs">
{t("sources")}
</Text>
<Stack gap={6}>
{Object.entries(widget.sources).map(([sourceId, source]) => (
<Box key={sourceId}>
<Text size="sm" fw={500}>
{source.name}
</Text>
<Group gap={4}>
<Badge size="xs" variant="light">
{new URL(source.baseUrl).origin}
</Badge>
<Badge size="xs" variant="outline">
{source.networkScope}
</Badge>
<Badge size="xs" variant="outline">
{typeof source.auth === "string" ? source.auth : source.auth.type}
</Badge>
</Group>
</Box>
))}
</Stack>
</Card>
<Card withBorder p="sm">
<Text fw={600} size="sm" mb="xs">
{t("requests")}
</Text>
{Object.keys(widget.requests).length === 0 ? (
<Text size="xs" c="dimmed">
{t("noRequests")}
</Text>
) : (
<Stack gap={6}>
{Object.entries(widget.requests).map(([requestId, request]) => (
<Group key={requestId} justify="space-between" wrap="nowrap">
<Text size="xs" ff="monospace" truncate>
{requestId}
</Text>
<Group gap={4} wrap="nowrap">
<Badge size="xs">{request.kind}</Badge>
<Badge size="xs" variant="light">
{request.method}
</Badge>
<Badge size="xs" variant="outline">
{request.permission}
</Badge>
</Group>
</Group>
))}
</Stack>
)}
</Card>
</SimpleGrid>
);
}
@@ -0,0 +1,14 @@
import { WorkshopBackend } from "@homarr/workshop/backend";
import { WORKSHOP_API_URL } from "@homarr/workshop/schema";
let browserClient: WorkshopBackend | undefined;
export function createWorkshopClient() {
if (typeof document === "undefined") return new WorkshopBackend(WORKSHOP_API_URL);
if (browserClient) return browserClient;
const runtimeUrl = document.querySelector<HTMLMetaElement>('meta[name="homarr-workshop-api-url"]')?.content;
browserClient = new WorkshopBackend(runtimeUrl || WORKSHOP_API_URL);
return browserClient;
}
@@ -0,0 +1,28 @@
"use client";
import { useState } from "react";
import { Button, Modal } from "@mantine/core";
import { IconDownload } from "@tabler/icons-react";
import { WorkshopBrowser } from "./workshop-browser";
export function WorkshopCssImportButton({ onImport }: { onImport(css: string): void }) {
const [opened, setOpened] = useState(false);
return (
<>
<Button type="button" variant="light" leftSection={<IconDownload size={16} />} onClick={() => setOpened(true)}>
Import from Workshop
</Button>
<Modal opened={opened} onClose={() => setOpened(false)} title="Import Custom CSS" size="xl">
<WorkshopBrowser
type="customCss"
onUseCss={(css) => {
onImport(css);
setOpened(false);
}}
/>
</Modal>
</>
);
}
@@ -0,0 +1,62 @@
"use client";
import type { ReactNode } from "react";
import { useState } from "react";
import { Button, getDefaultZIndex, Modal, useModalsStack } from "@mantine/core";
import { IconBuildingStore } from "@tabler/icons-react";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { useScopedI18n } from "@homarr/translation/client";
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
import { WorkshopBrowser } from "./workshop-browser";
interface WorkshopInstallButtonProps {
children?: ReactNode;
fullWidth?: boolean;
}
export function WorkshopInstallButton({ children, fullWidth }: WorkshopInstallButtonProps = {}) {
const t = useScopedI18n("workshop");
const stack = useModalsStack(["workshop", "review"]);
const [pendingWidget, setPendingWidget] = useState<HomarrCustomWidgetV2 | null>(null);
const zIndex = getDefaultZIndex("modal") + 10;
const closeReview = () => {
stack.close("review");
setPendingWidget(null);
};
return (
<>
<Button
variant="default"
leftSection={<IconBuildingStore size={16} />}
onClick={() => stack.open("workshop")}
fullWidth={fullWidth}
>
{children ?? t("title")}
</Button>
<Modal.Stack>
<Modal {...stack.register("workshop")} title={t("installDialog")} size="90%" zIndex={zIndex}>
<WorkshopBrowser
onInstall={async (widget) => {
setPendingWidget(widget);
stack.open("review");
}}
/>
</Modal>
<CustomWidgetImportDialog
{...stack.register("review")}
widget={pendingWidget}
zIndex={zIndex + 1}
onClose={closeReview}
onImported={() => {
stack.closeAll();
setPendingWidget(null);
}}
/>
</Modal.Stack>
</>
);
}
@@ -0,0 +1,36 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useDisclosure } from "@mantine/hooks";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
import { WorkshopBrowser } from "./workshop-browser";
export function WorkshopInstaller() {
const router = useRouter();
const [pendingWidget, setPendingWidget] = useState<HomarrCustomWidgetV2 | null>(null);
const [reviewOpened, reviewControls] = useDisclosure(false);
const install = async (widget: HomarrCustomWidgetV2) => {
setPendingWidget(widget);
reviewControls.open();
};
return (
<>
<WorkshopBrowser onInstall={install} />
<CustomWidgetImportDialog
opened={reviewOpened}
widget={pendingWidget}
onClose={() => {
reviewControls.close();
setPendingWidget(null);
}}
onImported={(result) => router.push(`/manage/custom-widgets/edit/${result.id}`)}
/>
</>
);
}
@@ -0,0 +1,142 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Alert, Button, Checkbox, FileInput, Group, Modal, Stack, Textarea, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { showErrorNotification } from "@homarr/notifications";
import { useWorkshopCreateMutation } from "@homarr/workshop/backend";
import type { WorkshopUser } from "@homarr/workshop/schema";
import { useScopedI18n } from "@homarr/translation/client";
import { createWorkshopClient } from "./workshop-client";
export function WorkshopPublishModal({
opened,
onClose,
widget,
}: {
opened: boolean;
onClose(): void;
widget: { id: string; name: string };
}) {
const t = useScopedI18n("workshop");
const client = useMemo(createWorkshopClient, []);
const utils = clientApi.useUtils();
const [user, setUser] = useState<WorkshopUser | null>(null);
const [title, setTitle] = useState(widget.name);
const [description, setDescription] = useState("");
const [screenshots, setScreenshots] = useState<File[]>([]);
const createSubmission = useWorkshopCreateMutation(client);
const [error, setError] = useState<string | null>(null);
const [published, setPublished] = useState(false);
const [sourceUrlsReviewed, setSourceUrlsReviewed] = useState(false);
const definition = clientApi.customWidget.get.useQuery({ id: widget.id }, { enabled: opened });
const privateSources = Object.entries(definition.data?.sources ?? {}).filter(
([, source]) => source.networkScope !== "public",
);
useEffect(() => {
const unsubscribe = client.subscribeToAuth(setUser);
void client.refreshAuth().then(setUser);
return unsubscribe;
}, [client]);
useEffect(() => {
if (!opened) return;
setTitle(widget.name);
setDescription("");
setScreenshots([]);
setError(null);
setPublished(false);
setSourceUrlsReviewed(false);
}, [opened, widget.name]);
const publish = async () => {
setError(null);
try {
const definition = await utils.customWidget.export.fetch({ id: widget.id });
await createSubmission.mutateAsync({
input: { type: "customWidget", title, description, content: JSON.stringify(definition) },
screenshots,
});
setPublished(true);
} catch (cause) {
setError(cause instanceof Error ? cause.message : t("publish.error"));
}
};
return (
<Modal opened={opened} onClose={onClose} title={t("publish.title")} size="lg">
<Stack>
{published ? (
<Alert color="green">{t("publish.success")}</Alert>
) : (
<>
{!user && (
<Alert color="blue">
<Group justify="space-between">
<span>{t("publish.signInHint")}</span>
<Button
size="xs"
onClick={() =>
void client
.signInWithGitHub()
.then(setUser)
.catch((cause: Error) => {
setError(cause.message);
showErrorNotification({ title: t("signIn"), message: cause.message });
})
}
>
{t("signIn")}
</Button>
</Group>
</Alert>
)}
<TextInput
label={t("publish.titleField")}
value={title}
onChange={(event) => setTitle(event.currentTarget.value)}
required
/>
<Textarea
label={t("publish.descriptionField")}
value={description}
onChange={(event) => setDescription(event.currentTarget.value)}
minRows={3}
/>
<FileInput
label={t("publish.screenshots")}
description={t("publish.screenshotsDescription")}
accept="image/png,image/jpeg,image/webp"
multiple
value={screenshots}
onChange={setScreenshots}
/>
{privateSources.length > 0 && (
<Alert color="yellow">
{t("publish.privateSourceWarning", {
sources: privateSources.map(([sourceId, source]) => source.name ?? sourceId).join(", "),
})}
<Checkbox
mt="sm"
checked={sourceUrlsReviewed}
onChange={(event) => setSourceUrlsReviewed(event.currentTarget.checked)}
label={t("publish.privateSourceConfirmation")}
/>
</Alert>
)}
{error && <Alert color="red">{error}</Alert>}
<Button
loading={createSubmission.isPending}
disabled={!user || title.trim().length < 3 || (privateSources.length > 0 && !sourceUrlsReviewed)}
onClick={() => void publish()}
>
{t("publish.action")}
</Button>
</>
)}
</Stack>
</Modal>
);
}