feat(workshop): rebuild install and publish as pages

Replace the stacked install modals (browse -> details -> import review)
with page-based flows so the primary action is always reachable:

- /manage/custom-widgets/workshop browses submissions as a card grid
  with large previews, sitting in a tab next to the installed widgets
- /manage/custom-widgets/workshop/[id] merges the listing, security
  review and source setup onto one page with a sticky install footer
- /manage/custom-widgets/publish/[id] replaces the publish modal
- /manage/workshop redirects; its navbar entry is gone
- the Custom CSS picker collapses from three modals to one

Extract useCustomWidgetImport and ImportReviewContent so the Workshop
page and the file/clipboard dialog share one import pipeline.

Votes now show the viewer's own choice filled Reddit-style, backed by a
new user-votes query, and screenshots get a zoomable lightbox.

Rework the installed list onto ManageCollectionItem: show the widget
description, replace the cryptic "JSX - 2" badge with plain request and
origin facts, and swap the toggle and bare pencil for a status badge,
a labelled Edit button and a menu entry for enable/disable.

Fixes found while reviewing:

- importing could run twice and duplicate a widget; guard on pending and
  succeeded, and return to the installed list instead of the heavy edit
  form
- a background refetch of the submission (window focus, or the
  invalidation a vote triggers) reset the source setup and silently wiped
  part-typed credentials; key the reset on the sources, not the object
- breadcrumbs raised MISSING_MESSAGE for unmapped dynamic segments; fall
  back to the raw segment via t.has
- publishing had no in-flight guard and could post duplicate listings
- tooltips on disabled vote and report controls never fired, making the
  sign-in hint unreachable
This commit is contained in:
Thomas Camlong committed 2026-08-17 01:14:38 +02:00
1 parent 50537d077c
commit 206905ea2d
38 files changed
+2303 -1700

No files matched your search

@@ -1,748 +0,0 @@
"use client";
import { useEffect, useId, useMemo, useState } from "react";
import {
Accordion,
ActionIcon,
Alert,
Badge,
Box,
Button,
Card,
Code,
Group,
Image,
Loader,
Modal,
Pagination,
Select,
SimpleGrid,
Skeleton,
Stack,
Text,
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,
} from "@tabler/icons-react";
import { CUSTOM_WIDGET_SCHEMA } from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { ReadOnlyCustomWidgetCode } from "@homarr/custom-widgets/workbench";
import type { CustomWidgetEditorMessages } from "@homarr/custom-widgets/workbench";
import {
useWorkshopCssImportMutation,
useWorkshopQuery,
useWorkshopReportSummariesQuery,
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 { useCurrentLocale, useScopedI18n } from "@homarr/translation/client";
import { createWorkshopClient, getWorkshopWebUrl } 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;
modalStack?: {
modalProps: Pick<ModalProps, "size" | "styles">;
details: WorkshopModalStep;
report: WorkshopModalStep;
};
}
interface WorkshopModalStep {
opened: boolean;
stackId?: string;
open(): void;
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<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 [internalReportOpened, 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 workshopWebUrl = useMemo(getWorkshopWebUrl, []);
const detailsOpened = modalStack?.details.opened ?? selectedId !== null;
const reportOpened = modalStack?.report.opened ?? internalReportOpened;
const openDetails = (id: string) => {
setSelectedId(id);
modalStack?.details.open();
};
const closeDetails = () => {
modalStack?.details.close();
setSelectedId(null);
};
const openReport = modalStack?.report.open ?? reportControls.open;
const closeReport = modalStack?.report.close ?? reportControls.close;
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 reportSummaries = useWorkshopReportSummariesQuery(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);
const useCss = useWorkshopCssImportMutation(onUseCss, () => setSelectedId(null));
const vote = useWorkshopVoteMutation(client);
const report = useWorkshopReportMutation(client);
const submitReport = () =>
report.mutate(
{ submission: selectedId ?? "", category: reportCategory, explanation: reportExplanation },
{
onSuccess: () => {
closeReport();
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>
<Group gap="xs">
<Button
component="a"
href={workshopWebUrl}
target="_blank"
rel="noopener noreferrer"
size="xs"
variant="default"
leftSection={<IconExternalLink size={14} />}
>
{t("openCommunity")}
</Button>
{user ? (
<>
<Badge variant="light">{user.name}</Badge>
<Button
size="xs"
variant="subtle"
onClick={() => {
client.signOut();
setUser(null);
}}
>
{t("signOut")}
</Button>
</>
) : (
<Button size="xs" loading={loginPending} onClick={signIn}>
{t("signIn")}
</Button>
)}
</Group>
</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="yellow" icon={<IconAlertTriangle size={18} />} title={t("unavailableTitle")}>
<Stack gap="sm" align="flex-start">
<Text size="sm">{t("unavailable")}</Text>
<Button
size="xs"
variant="light"
leftSection={<IconRefresh size={14} />}
loading={list.isFetching}
onClick={() => void list.refetch()}
>
{t("retry")}
</Button>
</Stack>
</Alert>
) : list.data?.items.length ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
{list.data.items.map((item) => (
<Card
key={item.id}
withBorder
radius="lg"
p={0}
h={440}
w="100%"
style={{ display: "flex", flexDirection: "column", overflow: "hidden" }}
>
{item.screenshots[0] ? (
<Card.Section>
<Image
src={client.fileUrl(item.id, item.screenshots[0], "480x320")}
h={190}
fit="cover"
alt={t("screenshotAlt", { title: item.title, count: 1 })}
/>
</Card.Section>
) : (
<Card.Section>
<Box
h={190}
bg="var(--mantine-color-default-hover)"
style={{ display: "grid", placeItems: "center" }}
>
<Code fz="md">{item.type === "customCss" ? "CSS" : "JSX"}</Code>
</Box>
</Card.Section>
)}
<Stack gap="sm" p="md" style={{ flex: 1 }}>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Text fw={700} size="lg" lineClamp={1} miw={0}>
{item.title}
</Text>
<Group
gap={7}
wrap="nowrap"
px="xs"
py={6}
bg="var(--mantine-color-default-hover)"
style={{ borderRadius: "var(--mantine-radius-md)", flexShrink: 0 }}
>
<VisuallyHidden>
{t("voteSummary", { upvotes: item.upvotes, downvotes: item.downvotes })}
</VisuallyHidden>
<IconArrowBigUp aria-hidden size={16} color="var(--mantine-primary-color-filled)" />
<Text aria-hidden size="sm" fw={700} lh={1}>
{item.score}
</Text>
<IconArrowBigDown aria-hidden size={16} color="var(--mantine-color-dimmed)" />
</Group>
</Group>
<Text size="xs" c="dimmed">
{t("author", { name: item.authorName || t("communityMember") })} · v{item.revision} ·{" "}
{dateFormatter.format(new Date(item.updated))}
</Text>
<Group gap="xs">
{item.outdated && <Badge color="yellow">{t("outdated")}</Badge>}
{item.reportCount > 0 && <Badge color="red">{t("reportCount", { count: item.reportCount })}</Badge>}
</Group>
<Text size="sm" c="dimmed" lineClamp={3}>
{item.description || t("noDescription")}
</Text>
</Stack>
<Card.Section withBorder p="sm" bg="var(--mantine-color-default-hover)">
<Group justify="space-between" wrap="nowrap">
<Group gap={6} c="dimmed" wrap="nowrap">
<IconMessageCircle size={16} />
<Text size="xs">{t("comments", { count: item.commentCount })}</Text>
</Group>
<Button
size="xs"
variant="light"
leftSection={<IconEye size={15} />}
onClick={() => openDetails(item.id)}
>
{t("viewDetails")}
</Button>
</Group>
</Card.Section>
</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
{...modalStack?.modalProps}
opened={detailsOpened}
onClose={closeDetails}
stackId={modalStack?.details.stackId}
title={type === "customCss" ? t("inspectCss") : t("install")}
size={modalStack?.modalProps.size ?? "xl"}
styles={detailsModalStyles}
>
{detail.isPending ? (
<Box p="lg">
<Skeleton h={400} />
</Box>
) : detail.isError || !detail.data ? (
<Box p="lg">
<Alert color="red">{detail.error instanceof Error ? detail.error.message : t("loadError")}</Alert>
</Box>
) : (
<>
<Box p="lg" style={{ flex: 1, minHeight: 0, overflowY: "auto" }}>
<Stack gap="lg">
<Group justify="space-between" align="flex-start">
<Box>
<Title order={3}>{detail.data.title}</Title>
<Text size="sm" c="dimmed">
{t("author", { name: detail.data.authorName || t("communityMember") })}
</Text>
</Box>
<Button
component="a"
href={getWorkshopWebUrl(detail.data.id)}
target="_blank"
rel="noopener noreferrer"
variant="subtle"
size="compact-sm"
rightSection={<IconExternalLink size={14} />}
>
{t("openCommunity")}
</Button>
</Group>
{detail.data.description && <Text c="dimmed">{detail.data.description}</Text>}
{detail.data.outdated && (
<Alert color="yellow" icon={<IconAlertTriangle size={18} />}>
{t("outdatedWarning")}
</Alert>
)}
{detail.data.reportCount > 0 && (
<Stack gap="xs">
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
{t("reportWarning", { count: detail.data.reportCount })}
</Alert>
{reportSummaries.data?.map((summary) => (
<Card key={summary.id} withBorder p="sm">
<Badge color="red" variant="light" mb="xs">
{t(`reportCategory.${summary.category}`)}
</Badge>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{summary.explanation}
</Text>
</Card>
))}
{reportSummaries.data?.length === 0 && (
<Text size="xs" c="dimmed">
{t("reportVisibility")}
</Text>
)}
</Stack>
)}
{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={t("screenshotAlt", { title: detail.data.title, count: index + 1 })}
/>
))}
</SimpleGrid>
)}
{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>
)}
<Accordion variant="contained" radius="md">
<Accordion.Item value="technical-details">
<Accordion.Control>{t("technicalDetails")}</Accordion.Control>
<Accordion.Panel>
<Stack gap="md">
{type === "customWidget" &&
detailValidation?.success &&
typeof detailValidation.data !== "string" && (
<CapabilitySummary widget={detailValidation.data} />
)}
<WorkshopCodeViewer
value={detail.data.content}
language={type === "customCss" ? "css" : "json"}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
{!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>
</Box>
<Box
px="lg"
py="md"
bg="var(--mantine-color-body)"
style={{ borderTop: "1px solid var(--mantine-color-default-border)", flexShrink: 0 }}
>
<Group justify="space-between" gap="sm">
<Group gap="xs">
<Button
variant="default"
size="sm"
leftSection={<IconDownload size={16} />}
onClick={() => downloadWorkshopSubmission(detail.data)}
>
{t("export")}
</Button>
<Group
gap={4}
wrap="nowrap"
p={4}
bg="var(--mantine-color-default-hover)"
style={{ borderRadius: "var(--mantine-radius-md)" }}
>
<ActionIcon
variant="subtle"
color="red"
size="lg"
loading={vote.isPending && vote.variables?.value === 1}
disabled={!user || vote.isPending}
aria-label={t("upvote", { count: detail.data.upvotes })}
onClick={() => vote.mutate({ submission: detail.data.id, value: 1 })}
>
<IconArrowBigUp size={20} />
</ActionIcon>
<Text miw={24} ta="center" fw={700} aria-label={t("score", { count: detail.data.score })}>
{detail.data.score}
</Text>
<ActionIcon
variant="subtle"
size="lg"
loading={vote.isPending && vote.variables?.value === -1}
disabled={!user || vote.isPending}
aria-label={t("downvote", { count: detail.data.downvotes })}
onClick={() => vote.mutate({ submission: detail.data.id, value: -1 })}
>
<IconArrowBigDown size={20} />
</ActionIcon>
</Group>
<Button
variant="subtle"
size="compact-sm"
color="red"
leftSection={<IconFlag size={16} />}
disabled={!user}
onClick={openReport}
>
{t("report")}
</Button>
</Group>
{onInstall && (
<Button
size="sm"
loading={install.isPending}
disabled={!detailCompatible}
rightSection={<IconArrowRight size={16} />}
onClick={() =>
detailValidation?.success &&
typeof detailValidation.data !== "string" &&
install.mutate(detailValidation.data)
}
>
{t("continueInstall")}
</Button>
)}
{onUseCss && (
<Button
size="sm"
loading={useCss.isPending}
disabled={!detailCompatible}
onClick={() =>
detailValidation?.success &&
typeof detailValidation.data === "string" &&
useCss.mutate(detailValidation.data)
}
>
{t("useCss")}
</Button>
)}
</Group>
</Box>
</>
)}
</Modal>
<Modal
{...modalStack?.modalProps}
opened={reportOpened}
onClose={closeReport}
stackId={modalStack?.report.stackId}
title={t("reportTitle")}
>
<Stack>
<Select
label={t("reportReason")}
value={reportCategory}
onChange={(value) => setReportCategory((value as WorkshopReport["category"]) ?? "other")}
data={(["outdated", "malicious", "spam", "copyright", "inappropriate", "other"] as const).map((value) => ({
value,
label: t(`reportCategory.${value}`),
}))}
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>
</Stack>
);
}
function WorkshopCodeViewer({ value, language }: { value: string; language: "json" | "css" }) {
const id = useId();
const t = useScopedI18n("customWidget.editor");
const workshopT = useScopedI18n("workshop");
const messages: CustomWidgetEditorMessages = {
languageJsx: t("language.jsx"),
languageJson: t("language.json"),
undo: t("action.undo"),
redo: t("action.redo"),
components: t("action.components"),
componentSearch: t("componentReference.search"),
componentEmpty: t("componentReference.empty"),
componentCount: (count) => t("componentReference.count", { count }),
insertStarter: t("action.insertStarter"),
format: t("action.format"),
copy: t("action.copy"),
copied: t("action.copied"),
schema: t("action.schema"),
schemaTab: t("reference.schema"),
minimalTab: t("reference.minimal"),
fullTab: t("reference.full"),
errors: (count) => t("status.errors", { count }),
warnings: (count) => t("status.warnings", { count }),
ready: t("status.ready"),
position: (cursor) => t("status.position", cursor),
characters: (count, limit) =>
limit ? t("status.charactersWithLimit", { count, limit }) : t("status.characters", { count }),
diagnosticsTitle: t("diagnostics.title"),
diagnostic: (diagnostic) => diagnostic.value ?? diagnostic.code,
};
let displayValue = value;
if (language === "json") {
try {
displayValue = JSON.stringify(JSON.parse(value) as unknown, null, 2);
} catch {}
}
return (
<ReadOnlyCustomWidgetCode
id={`workshop-source-${id}`}
label={workshopT("sourceCode")}
language={language}
value={displayValue}
messages={messages}
height="360px"
/>
);
}
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,66 @@
"use client";
import { useId } from "react";
import { ReadOnlyCustomWidgetCode } from "@homarr/custom-widgets/workbench";
import type { CustomWidgetEditorMessages } from "@homarr/custom-widgets/workbench";
import { useScopedI18n } from "@homarr/translation/client";
/** Read-only source view of a Workshop submission, used for both widget JSON and Custom CSS. */
export function WorkshopCodeViewer({
value,
language,
height = "360px",
}: {
value: string;
language: "json" | "css";
height?: string;
}) {
const id = useId();
const t = useScopedI18n("customWidget.editor");
const workshopT = useScopedI18n("workshop");
const messages: CustomWidgetEditorMessages = {
languageJsx: t("language.jsx"),
languageJson: t("language.json"),
undo: t("action.undo"),
redo: t("action.redo"),
components: t("action.components"),
componentSearch: t("componentReference.search"),
componentEmpty: t("componentReference.empty"),
componentCount: (count) => t("componentReference.count", { count }),
insertStarter: t("action.insertStarter"),
format: t("action.format"),
copy: t("action.copy"),
copied: t("action.copied"),
schema: t("action.schema"),
schemaTab: t("reference.schema"),
minimalTab: t("reference.minimal"),
fullTab: t("reference.full"),
errors: (count) => t("status.errors", { count }),
warnings: (count) => t("status.warnings", { count }),
ready: t("status.ready"),
position: (cursor) => t("status.position", cursor),
characters: (count, limit) =>
limit ? t("status.charactersWithLimit", { count, limit }) : t("status.characters", { count }),
diagnosticsTitle: t("diagnostics.title"),
diagnostic: (diagnostic) => diagnostic.value ?? diagnostic.code,
};
let displayValue = value;
if (language === "json") {
try {
displayValue = JSON.stringify(JSON.parse(value) as unknown, null, 2);
} catch {}
}
return (
<ReadOnlyCustomWidgetCode
id={`workshop-source-${id}`}
label={workshopT("sourceCode")}
language={language}
value={displayValue}
messages={messages}
height={height}
/>
);
}
@@ -1,60 +1,144 @@
"use client";
import { Button, Modal, useModalsStack } from "@mantine/core";
import type { ModalProps } from "@mantine/core";
import { IconDownload } from "@tabler/icons-react";
import { useState } from "react";
import { ActionIcon, Button, Group, Loader, Modal, Pagination, Stack, Text, TextInput, Tooltip } from "@mantine/core";
import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
import { IconDownload, IconExternalLink, IconSearch } from "@tabler/icons-react";
import { WorkshopBrowser } from "./workshop-browser";
import { modalSizeSelect, useConfirmModal } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useWorkshopQuery } from "@homarr/workshop/backend";
import type { WorkshopSubmissionSummary } from "@homarr/workshop/schema";
import { validateWorkshopContent } from "@homarr/workshop/schema";
const cssInstallModalProps = {
size: "90%",
styles: {
content: { display: "flex", flexDirection: "column", height: "min(85dvh, 900px)" },
body: { flex: 1, overflowY: "auto" },
},
} satisfies Pick<ModalProps, "size" | "styles">;
import { NoResults } from "~/components/no-results";
import { getWorkshopWebUrl } from "./workshop-client";
import { useWorkshopSession } from "./workshop-session";
import { WorkshopSubmissionGrid } from "./workshop-submission-grid";
import { WorkshopVoteControl } from "./workshop-vote-control";
/**
* Picks a community CSS theme in a single surface: browse, confirm, done.
* The imported rules land in the editor below and are only persisted when the
* board settings themselves are saved, so there is nothing to preview twice.
*/
export function WorkshopCssImportButton({ onImport }: { onImport(css: string): void }) {
const stack = useModalsStack(["workshop", "details", "report"]);
const detailsModal = stack.register("details");
const reportModal = stack.register("report");
const t = useScopedI18n("workshop");
const [opened, controls] = useDisclosure(false);
return (
<>
<Button
type="button"
variant="light"
leftSection={<IconDownload size={16} />}
onClick={() => stack.open("workshop")}
>
Import from Workshop
<Button type="button" variant="light" leftSection={<IconDownload size={16} />} onClick={controls.open}>
{t("importCss")}
</Button>
<Modal.Stack>
<Modal {...stack.register("workshop")} {...cssInstallModalProps} title="Import Custom CSS">
<WorkshopBrowser
type="customCss"
modalStack={{
modalProps: cssInstallModalProps,
details: {
opened: detailsModal.opened,
stackId: detailsModal.stackId,
open: () => stack.open("details"),
close: () => stack.close("details"),
},
report: {
opened: reportModal.opened,
stackId: reportModal.stackId,
open: () => stack.open("report"),
close: () => stack.close("report"),
},
}}
onUseCss={(css) => {
onImport(css);
stack.closeAll();
}}
/>
</Modal>
</Modal.Stack>
<Modal opened={opened} onClose={controls.close} title={t("importCss")} size={modalSizeSelect} radius="md">
<WorkshopCssPicker
onImport={(css) => {
onImport(css);
controls.close();
}}
/>
</Modal>
</>
);
}
function WorkshopCssPicker({ onImport }: { onImport(css: string): void }) {
const t = useScopedI18n("workshop");
const session = useWorkshopSession();
const { openConfirmModal } = useConfirmModal();
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 250);
const [page, setPage] = useState(1);
const [pendingId, setPendingId] = useState<string | null>(null);
const query = useWorkshopQuery(session.client, {
page,
perPage: 8,
type: "customCss",
sort: "top",
search: debouncedSearch,
});
const totalPages = query.data?.totalPages ?? 1;
const useTheme = async (item: WorkshopSubmissionSummary) => {
setPendingId(item.id);
try {
const submission = await session.client.get(item.id);
const validation = validateWorkshopContent("customCss", submission.content);
if (!validation.success) throw new Error(validation.error);
const css = validation.data;
if (typeof css !== "string") throw new Error(t("loadError"));
openConfirmModal({
title: t("importCss"),
children: t("importCssConfirm", { title: item.title }),
onConfirm: () => onImport(css),
});
} catch (cause) {
showErrorNotification({
title: t("importCss"),
message: cause instanceof Error ? cause.message : t("loadError"),
});
} finally {
setPendingId(null);
}
};
return (
<Stack gap="md">
<Text size="sm" c="dimmed">
{t("securityNoticeCss")}
</Text>
<TextInput
aria-label={t("searchCss")}
placeholder={`${t("searchCss")}...`}
leftSection={<IconSearch size={16} />}
rightSection={query.isFetching ? <Loader size={16} /> : undefined}
value={search}
onChange={(event) => {
setSearch(event.currentTarget.value);
setPage(1);
}}
/>
<WorkshopSubmissionGrid
client={session.client}
query={query}
ariaLabel={t("listAriaLabelCss")}
cols={{ base: 1, sm: 2 }}
emptyState={<NoResults icon={IconSearch} title={t("emptyCss")} />}
renderActions={(item) => (
<>
<WorkshopVoteControl
client={session.client}
submissionId={item.id}
score={item.score}
canVote={session.user !== null}
size="sm"
/>
<Group gap={4} wrap="nowrap">
<Tooltip label={t("openCommunity")}>
<ActionIcon
component="a"
href={getWorkshopWebUrl(item.id)}
target="_blank"
rel="noopener noreferrer"
variant="subtle"
color="gray"
size="lg"
aria-label={t("openOnWorkshop", { title: item.title })}
>
<IconExternalLink size={16} stroke={1.5} />
</ActionIcon>
</Tooltip>
<Button size="sm" loading={pendingId === item.id} onClick={() => void useTheme(item)}>
{t("useCss")}
</Button>
</Group>
</>
)}
/>
{totalPages > 1 && <Pagination total={totalPages} value={page} onChange={setPage} mx="auto" />}
</Stack>
);
}
@@ -1,110 +0,0 @@
"use client";
import type { ReactNode } from "react";
import { useState } from "react";
import { Button, Modal, useModalsStack } from "@mantine/core";
import type { ModalProps } 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;
}
const installFlowModalProps = {
size: "90%",
styles: {
content: { display: "flex", flexDirection: "column", height: "min(85dvh, 900px)" },
body: { flex: 1, overflowY: "auto" },
},
} satisfies Pick<ModalProps, "size" | "styles">;
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<ModalProps, "size" | "styles">;
export function WorkshopInstallButton({ children, fullWidth }: WorkshopInstallButtonProps = {}) {
const t = useScopedI18n("workshop");
const stack = useModalsStack(["workshop", "details", "report", "review"]);
const [pendingWidget, setPendingWidget] = useState<HomarrCustomWidgetV2 | null>(null);
const detailsModal = stack.register("details");
const reportModal = stack.register("report");
const returnToDetails = () => {
stack.close("review");
setPendingWidget(null);
stack.open("details");
};
return (
<>
<Button
variant="default"
leftSection={<IconBuildingStore size={16} />}
onClick={() => stack.open("workshop")}
fullWidth={fullWidth}
>
{children ?? t("importFromWorkshop")}
</Button>
<Modal.Stack>
<Modal {...stack.register("workshop")} {...installFlowModalProps} title={t("installDialog")}>
<WorkshopBrowser
modalStack={{
modalProps: installFlowModalProps,
details: {
opened: detailsModal.opened,
stackId: detailsModal.stackId,
open: () => stack.open("details"),
close: () => stack.close("details"),
},
report: {
opened: reportModal.opened,
stackId: reportModal.stackId,
open: () => stack.open("report"),
close: () => stack.close("report"),
},
}}
onInstall={async (widget) => {
setPendingWidget(widget);
stack.close("details");
stack.open("review");
}}
/>
</Modal>
<CustomWidgetImportDialog
{...stack.register("review")}
widget={pendingWidget}
modalProps={installReviewModalProps}
labels={{
title: t("confirmInstallTitle"),
description: t("confirmInstallDescription"),
cancel: t("backToWidget"),
confirm: t("install"),
}}
onClose={returnToDetails}
onImported={() => {
stack.closeAll();
setPendingWidget(null);
}}
/>
</Modal.Stack>
</>
);
}
@@ -5,6 +5,13 @@ import { afterEach, describe, expect, it } from "vitest";
import { getWorkshopAssistantProviderUrl, getWorkshopWebUrl } from "./workshop-client";
const read = (path: string) => readFileSync(`${process.cwd()}/${path}`, "utf8");
const detailPage = "apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/[id]/_workshop-detail.tsx";
const browsePage = "apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/_workshop-browse.tsx";
const publishPage = "apps/nextjs/src/app/[locale]/manage/custom-widgets/publish/[id]/_workshop-publish-form.tsx";
const cssButton = "apps/nextjs/src/components/workshop/workshop-css-import-button.tsx";
describe("Workshop widget install flow", () => {
afterEach(() => {
document.head.innerHTML = "";
@@ -32,32 +39,124 @@ describe("Workshop widget install flow", () => {
expect(getWorkshopAssistantProviderUrl()).toBe("https://api.preview.example/api/ai/v1");
});
it("uses one registered modal stack for browsing, details, reports, and confirmation", () => {
const installButton = readFileSync(
`${process.cwd()}/apps/nextjs/src/components/workshop/workshop-install-button.tsx`,
"utf8",
);
const browser = readFileSync(`${process.cwd()}/apps/nextjs/src/components/workshop/workshop-browser.tsx`, "utf8");
it("installs from a page with a sticky footer instead of stacked modals", () => {
const detail = read(detailPage);
expect(installButton).toContain('useModalsStack(["workshop", "details", "report", "review"])');
expect(installButton).toContain("<Modal.Stack>");
expect(installButton).toContain('height: "min(85dvh, 900px)"');
expect(installButton).not.toContain("getDefaultZIndex");
expect(browser).toContain("ReadOnlyCustomWidgetCode");
expect(browser).not.toContain("{item.widgetSchema}");
expect(detail).toContain("ManageStickyFooter");
expect(detail).toContain("useCustomWidgetImport");
// Review and install happen on one surface, so neither Modal.Stack nor the review dialog is involved.
expect(detail).not.toContain("Modal.Stack");
expect(detail).not.toContain("useModalsStack");
expect(detail).not.toContain("CustomWidgetImportDialog");
});
it("uses a two-step modal stack for Custom CSS", () => {
const cssButton = readFileSync(
`${process.cwd()}/apps/nextjs/src/components/workshop/workshop-css-import-button.tsx`,
"utf8",
);
const browser = readFileSync(`${process.cwd()}/apps/nextjs/src/components/workshop/workshop-browser.tsx`, "utf8");
it("browses the Workshop as a card grid with per-item Workshop links", () => {
const browse = read(browsePage);
expect(cssButton).toContain('useModalsStack(["workshop", "details", "report"])');
expect(cssButton).toContain("<Modal.Stack>");
expect(cssButton).toContain('height: "min(85dvh, 900px)"');
expect(browser).not.toContain("cssAwaitingConfirmation");
expect(browser).toContain("useWorkshopReportSummariesQuery");
expect(browse).toContain("WorkshopSubmissionGrid");
expect(browse).toContain("SearchInput");
expect(browse).toContain("TablePagination");
expect(browse).toContain("CustomWidgetTabs");
expect(browse).toContain("getWorkshopWebUrl(item.id)");
expect(browse).toContain("WorkshopVoteControl");
expect(browse).not.toContain("Modal");
});
it("renders the grid with real cards and screenshot previews", () => {
const grid = read("apps/nextjs/src/components/workshop/workshop-submission-grid.tsx");
const card = read("apps/nextjs/src/components/workshop/workshop-submission-card.tsx");
expect(grid).toContain("SimpleGrid");
expect(card).toContain("Card.Section");
expect(card).toContain("PREVIEW_HEIGHT");
});
it("shows the viewer's own vote as a filled control", () => {
const vote = read("apps/nextjs/src/components/workshop/workshop-vote-control.tsx");
expect(vote).toContain("useWorkshopUserVotesQuery");
expect(vote).toContain("IconArrowBigUpFilled");
expect(vote).toContain("IconArrowBigDownFilled");
expect(vote).toContain('color={upvoted ? "red" : "gray"}');
expect(vote).toContain("aria-pressed={upvoted}");
});
it("previews screenshots large with a zoom lightbox", () => {
const screenshots = read("apps/nextjs/src/components/workshop/workshop-screenshots.tsx");
const detail = read(detailPage);
expect(screenshots).toContain("1200x800");
expect(screenshots).toContain("zoomScreenshot");
expect(detail).toContain("WorkshopScreenshots");
});
it("publishes from a page with the same sticky footer", () => {
const publish = read(publishPage);
expect(publish).toContain("ManageStickyFooter");
expect(publish).not.toContain("useModalsStack");
expect(publish).not.toMatch(/<Modal\b/u);
});
it("imports Custom CSS from a single modal", () => {
const css = read(cssButton);
expect(css).toContain("WorkshopSubmissionGrid");
expect(css).toContain("useConfirmModal");
expect(css).not.toContain("useModalsStack");
expect(css).not.toContain("Modal.Stack");
});
it("cannot import the same widget twice and returns to the installed list", () => {
const importer = read("apps/nextjs/src/components/custom-widgets/use-custom-widget-import.ts");
const detail = read(detailPage);
// The mutation creates a new record per call, so a second click must be impossible.
expect(importer).toContain("if (!configuredWidget || pending || succeeded) return;");
expect(importer).toContain("setSucceeded(true)");
expect(detail).toContain("router.push(installedHref)");
expect(detail).toContain("disabled={!compatible || !importer.ready || importer.succeeded}");
// The heavy edit form is no longer the post-install destination.
expect(detail).not.toContain("/manage/custom-widgets/edit/");
});
it("keeps typed credentials across a background refetch of the submission", () => {
const importer = read("apps/nextjs/src/components/custom-widgets/use-custom-widget-import.ts");
const detail = read(detailPage);
// Voting invalidates the detail query. If the setup reset keyed off the widget
// object identity, the refetch would silently clear part-typed API keys.
expect(importer).toContain("const sourcesKey = JSON.stringify(widget?.sources ?? {})");
expect(importer).toContain("[sourcesKey]");
expect(importer).not.toContain("getCustomWidgetSourceSetups(widget?.sources ?? {}), [widget])");
expect(detail).toContain("const content = detail.data?.content;");
expect(detail).toContain("[content]");
});
it("keeps the sign-in hint reachable when voting is unavailable", () => {
const vote = read("apps/nextjs/src/components/workshop/workshop-vote-control.tsx");
// Mantine tooltips do not fire on disabled controls, so the hint hangs off the wrapper.
expect(vote).toContain('canVote ? controls : <Tooltip label={t("signInHint")}>{controls}</Tooltip>');
});
it("cannot publish the same widget twice", () => {
const publish = read(publishPage);
expect(publish).toContain("if (createSubmission.isPending) return;");
expect(publish).toContain("createSubmission.isPending ||");
});
it("no longer ships the stacked-modal install components", () => {
for (const removed of [
"apps/nextjs/src/components/workshop/workshop-browser.tsx",
"apps/nextjs/src/components/workshop/workshop-install-button.tsx",
"apps/nextjs/src/components/workshop/workshop-installer.tsx",
"apps/nextjs/src/components/workshop/workshop-publish-modal.tsx",
"apps/nextjs/src/components/workshop/workshop-submission-list.tsx",
"apps/nextjs/src/components/workshop/workshop-submission-item.tsx",
]) {
expect(() => read(removed)).toThrow();
}
});
});
@@ -1,36 +0,0 @@
"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}`)}
/>
</>
);
}
@@ -1,275 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
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";
import { useWorkshopCreateMutation } from "@homarr/workshop/backend";
import { workshopScreenshotsSchema } from "@homarr/workshop/schema";
import type { WorkshopUser } from "@homarr/workshop/schema";
import { useScopedI18n } from "@homarr/translation/client";
import { createWorkshopClient, getWorkshopWebUrl } from "./workshop-client";
import {
getPrivateWorkshopSourceNames,
publishWorkshopDefinition,
serializeWorkshopDefinition,
} from "./workshop-publish-definition";
export function WorkshopPublishModal({
opened,
onClose,
widget,
}: {
opened: boolean;
onClose(): void;
widget: { id: string; name: string };
}) {
const t = useScopedI18n("workshop");
const client = useMemo(createWorkshopClient, []);
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 [publishedSubmissionId, setPublishedSubmissionId] = useState<string | null>(null);
const [sourceUrlsReviewed, setSourceUrlsReviewed] = useState(false);
const definition = clientApi.customWidget.export.useQuery({ id: widget.id }, { enabled: opened });
const privateSourceNames = getPrivateWorkshopSourceNames(definition.data);
const definitionFingerprint = definition.data ? serializeWorkshopDefinition(definition.data) : null;
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);
setPublishedSubmissionId(null);
setSourceUrlsReviewed(false);
}, [opened, widget.name]);
useEffect(() => setSourceUrlsReviewed(false), [definitionFingerprint]);
const publish = async () => {
setError(null);
try {
if (!definition.data) throw new Error(t("publish.error"));
if (!workshopScreenshotsSchema.safeParse(screenshots).success) {
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: 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"));
}
};
return (
<Modal opened={opened} onClose={onClose} title={t("publish.title")} size="lg" radius="lg">
<Stack gap="lg">
{publishedSubmissionId ? (
<Stack align="center" gap="lg" py="md">
<ThemeIcon size={64} radius="xl" color="green" variant="light">
<IconCheck size={32} stroke={2} />
</ThemeIcon>
<Box ta="center">
<Title order={3}>{t("publish.successTitle")}</Title>
<Text c="dimmed" size="sm" maw={480} mt={6}>
{t("publish.success")}
</Text>
</Box>
<Paper withBorder radius="md" p="md" w="100%">
<Group wrap="nowrap" align="flex-start">
<ThemeIcon variant="light" color="blue" radius="md" size="lg">
<IconBuildingStore size={20} />
</ThemeIcon>
<Box>
<Text fw={600} size="sm">
{t("publish.manageTitle")}
</Text>
<Text c="dimmed" size="sm" mt={2}>
{t("publish.manageDescription")}
</Text>
</Box>
</Group>
</Paper>
<Group justify="center">
<Button variant="default" onClick={onClose}>
{t("publish.done")}
</Button>
<Button
component="a"
href={getWorkshopWebUrl(publishedSubmissionId)}
target="_blank"
rel="noopener noreferrer"
rightSection={<IconExternalLink size={16} />}
>
{t("publish.viewSubmission")}
</Button>
</Group>
</Stack>
) : (
<>
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-default-hover)">
<Group wrap="nowrap" align="flex-start">
<ThemeIcon variant="light" radius="md" size="lg">
<IconBuildingStore size={20} />
</ThemeIcon>
<Box>
<Text fw={600}>{t("publish.introTitle")}</Text>
<Text c="dimmed" size="sm" mt={2}>
{t("publish.introDescription")}
</Text>
</Box>
</Group>
</Paper>
{!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>
)}
<Paper withBorder radius="md" p="md">
<Stack gap="md">
<Box>
<Text fw={600} size="sm">
{t("publish.listingDetails")}
</Text>
<Text c="dimmed" size="xs" mt={2}>
{t("publish.listingDetailsDescription")}
</Text>
</Box>
<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={4}
autosize
maxRows={7}
/>
<FileInput
label={t("publish.screenshots")}
description={t("publish.screenshotsDescription")}
placeholder={t("publish.screenshotsPlaceholder")}
leftSection={<IconPhoto size={16} />}
accept="image/png,image/jpeg,image/webp"
multiple
value={screenshots}
onChange={setScreenshots}
clearable
/>
</Stack>
</Paper>
{privateSourceNames.length > 0 && (
<Alert color="yellow" icon={<IconShieldCheck size={18} />} title={t("publish.reviewSourcesTitle")}>
{t("publish.privateSourceWarning", {
sources: privateSourceNames.join(", "),
})}
<Checkbox
mt="sm"
checked={sourceUrlsReviewed}
onChange={(event) => setSourceUrlsReviewed(event.currentTarget.checked)}
label={t("publish.privateSourceConfirmation")}
/>
</Alert>
)}
{definition.isError && <Alert color="red">{t("publish.error")}</Alert>}
{error && <Alert color="red">{error}</Alert>}
<Divider />
<Group justify="space-between" align="center">
<Text c="dimmed" size="xs" maw={360}>
{t("publish.publicationNote")}
</Text>
<Group gap="sm">
<Button variant="default" onClick={onClose}>
{t("publish.cancel")}
</Button>
<Button
loading={createSubmission.isPending || definition.isFetching}
disabled={
!user ||
!definition.data ||
definition.isError ||
title.trim().length < 3 ||
(privateSourceNames.length > 0 && !sourceUrlsReviewed)
}
onClick={() => void publish()}
>
{t("publish.action")}
</Button>
</Group>
</Group>
</>
)}
</Stack>
</Modal>
);
}
@@ -0,0 +1,79 @@
"use client";
import { useState } from "react";
import { Alert, Button, Group, Modal, Select, Stack, Textarea } from "@mantine/core";
import { showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import type { WorkshopBackend } from "@homarr/workshop/backend";
import { useWorkshopReportMutation } from "@homarr/workshop/backend";
import type { WorkshopReport } from "@homarr/workshop/schema";
const categories = ["outdated", "malicious", "spam", "copyright", "inappropriate", "other"] as const;
interface WorkshopReportModalProps {
client: WorkshopBackend;
submissionId: string;
opened: boolean;
onClose(): void;
}
export function WorkshopReportModal({ client, submissionId, opened, onClose }: WorkshopReportModalProps) {
const t = useScopedI18n("workshop");
const [category, setCategory] = useState<WorkshopReport["category"]>("other");
const [explanation, setExplanation] = useState("");
const report = useWorkshopReportMutation(client);
const close = () => {
setExplanation("");
report.reset();
onClose();
};
const submit = () =>
report.mutate(
{ submission: submissionId, category, explanation },
{
onSuccess: () => {
showSuccessNotification({
title: t("reportSent"),
message: t("reportSentDescription"),
});
close();
},
},
);
return (
<Modal opened={opened} onClose={close} title={t("reportTitle")} centered radius="md">
<Stack>
<Select
label={t("reportReason")}
value={category}
onChange={(value) => setCategory((value as WorkshopReport["category"] | null) ?? "other")}
data={categories.map((value) => ({
value,
label: t(`reportCategory.${value}`),
}))}
allowDeselect={false}
/>
<Textarea
label={t("reportExplanation")}
minRows={4}
autosize
value={explanation}
onChange={(event) => setExplanation(event.currentTarget.value)}
/>
{report.error && <Alert color="red">{report.error.message}</Alert>}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={close}>
{t("cancel")}
</Button>
<Button color="red" loading={report.isPending} disabled={explanation.trim().length < 3} onClick={submit}>
{t("reportSend")}
</Button>
</Group>
</Stack>
</Modal>
);
}
@@ -0,0 +1,141 @@
"use client";
import { useState } from "react";
import { ActionIcon, Box, Group, Image, Modal, Paper, Tooltip, UnstyledButton } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconChevronLeft, IconChevronRight, IconZoomIn } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import type { WorkshopBackend } from "@homarr/workshop/backend";
interface WorkshopScreenshotsProps {
client: WorkshopBackend;
submissionId: string;
title: string;
screenshots: string[];
}
/**
* A large preview of the submission's screenshots with a thumbnail strip and a
* click-to-zoom lightbox, so people can actually see what they are installing.
*/
export function WorkshopScreenshots({ client, submissionId, title, screenshots }: WorkshopScreenshotsProps) {
const t = useScopedI18n("workshop");
const [index, setIndex] = useState(0);
const [zoomOpened, zoomControls] = useDisclosure(false);
if (screenshots.length === 0) return null;
const active = screenshots[Math.min(index, screenshots.length - 1)];
if (!active) return null;
const alt = (position: number) => t("screenshotAlt", { title, count: position + 1 });
const step = (delta: number) => setIndex((current) => (current + delta + screenshots.length) % screenshots.length);
return (
<>
<Paper withBorder radius="md" p={0} style={{ overflow: "hidden", position: "relative" }}>
<UnstyledButton onClick={zoomControls.open} w="100%" aria-label={t("zoomScreenshot")}>
<Image
src={client.fileUrl(submissionId, active, "1200x800")}
alt={alt(index)}
fit="contain"
h={{ base: 220, sm: 380 }}
bg="var(--mantine-color-default-hover)"
/>
</UnstyledButton>
<Tooltip label={t("zoomScreenshot")}>
<ActionIcon
variant="default"
size="lg"
radius="md"
pos="absolute"
top={8}
right={8}
aria-label={t("zoomScreenshot")}
onClick={zoomControls.open}
>
<IconZoomIn size={18} stroke={1.5} />
</ActionIcon>
</Tooltip>
{screenshots.length > 1 && (
<>
<ActionIcon
variant="default"
size="lg"
radius="xl"
pos="absolute"
left={8}
top="50%"
style={{ transform: "translateY(-50%)" }}
aria-label={t("previousScreenshot")}
onClick={() => step(-1)}
>
<IconChevronLeft size={18} />
</ActionIcon>
<ActionIcon
variant="default"
size="lg"
radius="xl"
pos="absolute"
right={8}
top="50%"
style={{ transform: "translateY(-50%)" }}
aria-label={t("nextScreenshot")}
onClick={() => step(1)}
>
<IconChevronRight size={18} />
</ActionIcon>
</>
)}
</Paper>
{screenshots.length > 1 && (
<Group gap="xs" wrap="wrap">
{screenshots.map((file, position) => (
<UnstyledButton
key={file}
onClick={() => setIndex(position)}
aria-label={alt(position)}
aria-pressed={position === index}
>
<Box
w={96}
h={64}
style={{
overflow: "hidden",
borderRadius: "var(--mantine-radius-sm)",
outline:
position === index
? "2px solid var(--mantine-primary-color-filled)"
: "1px solid var(--mantine-color-default-border)",
opacity: position === index ? 1 : 0.65,
}}
>
<Image src={client.fileUrl(submissionId, file, "192x128")} alt="" h={64} w={96} fit="cover" />
</Box>
</UnstyledButton>
))}
</Group>
)}
<Modal
opened={zoomOpened}
onClose={zoomControls.close}
size="auto"
centered
padding="xs"
title={alt(index)}
styles={{ body: { display: "flex", justifyContent: "center" } }}
>
<Image
src={client.fileUrl(submissionId, active)}
alt={alt(index)}
fit="contain"
mah="80dvh"
style={{ maxWidth: "min(90vw, 1400px)" }}
/>
</Modal>
</>
);
}
@@ -0,0 +1,79 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Button, Group, Text } from "@mantine/core";
import { IconBrandGithub } from "@tabler/icons-react";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import type { WorkshopUser } from "@homarr/workshop/schema";
import { createWorkshopClient } from "./workshop-client";
/**
* Shared Workshop identity. Signing in is only needed to vote or report,
* never to install, so every surface can render this without gating its content.
*/
export function useWorkshopSession() {
const t = useScopedI18n("workshop");
const client = useMemo(createWorkshopClient, []);
const [user, setUser] = useState<WorkshopUser | null>(null);
const [pending, setPending] = useState(false);
useEffect(() => {
const unsubscribe = client.subscribeToAuth(setUser);
void client.refreshAuth().then(setUser);
return unsubscribe;
}, [client]);
const signIn = () => {
setPending(true);
void client
.signInWithGitHub()
.then(setUser)
.catch((cause: unknown) => {
showErrorNotification({
title: t("signIn"),
message: cause instanceof Error ? cause.message : t("signInError"),
});
})
.finally(() => setPending(false));
};
const signOut = () => {
client.signOut();
setUser(null);
};
return { client, user, pending, signIn, signOut };
}
export type WorkshopSession = ReturnType<typeof useWorkshopSession>;
export function WorkshopAccountButton({ session }: { session: WorkshopSession }) {
const t = useScopedI18n("workshop");
if (!session.user) {
return (
<Button
variant="default"
leftSection={<IconBrandGithub size={16} />}
loading={session.pending}
onClick={session.signIn}
>
{t("signIn")}
</Button>
);
}
return (
<Group gap="xs" wrap="nowrap">
<Text size="sm" c="dimmed" lineClamp={1}>
{session.user.name}
</Text>
<Button variant="subtle" color="gray" size="compact-sm" onClick={session.signOut}>
{t("signOut")}
</Button>
</Group>
);
}
@@ -0,0 +1,84 @@
"use client";
import type { ReactNode } from "react";
import { Badge, Box, Card, Group, Image, Stack, Text, Tooltip } from "@mantine/core";
import { IconAlertTriangle, IconFlag, IconPhoto } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import type { WorkshopBackend } from "@homarr/workshop/backend";
import type { WorkshopSubmissionSummary } from "@homarr/workshop/schema";
/** Aspect-ratio-preserving preview strip. Large enough to actually judge a widget by. */
const PREVIEW_HEIGHT = 168;
interface WorkshopSubmissionCardProps {
client: WorkshopBackend;
item: WorkshopSubmissionSummary;
/** Rendered in the card footer, e.g. score, external link and the install button. */
actions: ReactNode;
}
export function WorkshopSubmissionCard({ client, item, actions }: WorkshopSubmissionCardProps) {
const t = useScopedI18n("workshop");
const screenshot = item.screenshots[0];
return (
<Card component="li" withBorder radius="md" padding="md" h="100%">
<Card.Section>
{screenshot ? (
<Image
src={client.fileUrl(item.id, screenshot, "600x400")}
h={PREVIEW_HEIGHT}
fit="cover"
alt={t("screenshotAlt", { title: item.title, count: 1 })}
/>
) : (
<Box
h={PREVIEW_HEIGHT}
bg="var(--mantine-color-default-hover)"
style={{ display: "grid", placeItems: "center" }}
>
<Stack gap={4} align="center">
<IconPhoto size={28} stroke={1.5} opacity={0.5} />
<Text size="xs" c="dimmed" fw={600}>
{item.type === "customCss" ? "CSS" : "JSX"}
</Text>
</Stack>
</Box>
)}
</Card.Section>
<Stack gap="xs" mt="md" style={{ flex: 1 }}>
<Group gap={6} wrap="wrap">
{item.outdated && (
<Badge size="sm" variant="light" color="yellow" leftSection={<IconAlertTriangle size={11} />}>
{t("outdated")}
</Badge>
)}
{item.reportCount > 0 && (
<Tooltip label={t("reportWarning", { count: item.reportCount })} multiline maw={320}>
<Badge size="sm" variant="light" color="red" leftSection={<IconFlag size={11} />}>
{t("reportCount", { count: item.reportCount })}
</Badge>
</Tooltip>
)}
</Group>
<Box>
<Text fw={600} lineClamp={1}>
{item.title}
</Text>
<Text size="xs" c="dimmed">
{t("author", { name: item.authorName || t("communityMember") })}
</Text>
</Box>
<Text size="sm" c="dimmed" lineClamp={2} style={{ flex: 1 }}>
{item.description || t("noDescription")}
</Text>
</Stack>
<Group justify="space-between" align="center" gap="xs" wrap="nowrap" mt="md">
{actions}
</Group>
</Card>
);
}
@@ -0,0 +1,84 @@
"use client";
import type { ReactNode } from "react";
import { Alert, Button, SimpleGrid, Skeleton, Stack, Text } from "@mantine/core";
import type { StyleProp } from "@mantine/core";
import { IconAlertTriangle, IconRefresh } from "@tabler/icons-react";
import type { UseQueryResult } from "@tanstack/react-query";
import { useScopedI18n } from "@homarr/translation/client";
import type { WorkshopBackend, WorkshopPage } from "@homarr/workshop/backend";
import type { WorkshopSubmissionSummary } from "@homarr/workshop/schema";
import { WorkshopSubmissionCard } from "./workshop-submission-card";
interface WorkshopSubmissionGridProps {
client: WorkshopBackend;
query: UseQueryResult<WorkshopPage<WorkshopSubmissionSummary>>;
ariaLabel: string;
emptyState: ReactNode;
cols?: StyleProp<number>;
renderActions(item: WorkshopSubmissionSummary): ReactNode;
}
/**
* Loading, failure, empty and loaded states for a page of Workshop submissions.
* Shared so the widget browser and the Custom CSS picker behave identically.
*/
export function WorkshopSubmissionGrid({
client,
query,
ariaLabel,
emptyState,
cols = { base: 1, sm: 2, lg: 3 },
renderActions,
}: WorkshopSubmissionGridProps) {
const t = useScopedI18n("workshop");
if (query.isLoading) {
return (
<SimpleGrid cols={cols} spacing="md">
{[0, 1, 2, 3, 4, 5].map((index) => (
<Skeleton key={index} h={320} radius="md" />
))}
</SimpleGrid>
);
}
if (query.isError) {
return (
<Alert color="yellow" icon={<IconAlertTriangle size={18} />} title={t("unavailableTitle")}>
<Stack gap="sm" align="flex-start">
<Text size="sm">{t("unavailable")}</Text>
<Button
size="xs"
variant="light"
leftSection={<IconRefresh size={14} />}
loading={query.isFetching}
onClick={() => void query.refetch()}
>
{t("retry")}
</Button>
</Stack>
</Alert>
);
}
if (!query.data?.items.length) return <>{emptyState}</>;
return (
<SimpleGrid
component="ul"
aria-label={ariaLabel}
cols={cols}
spacing="md"
m={0}
p={0}
style={{ listStyle: "none" }}
>
{query.data.items.map((item) => (
<WorkshopSubmissionCard key={item.id} client={client} item={item} actions={renderActions(item)} />
))}
</SimpleGrid>
);
}
@@ -0,0 +1,90 @@
"use client";
import { ActionIcon, Group, Text, Tooltip } from "@mantine/core";
import { IconArrowBigDown, IconArrowBigDownFilled, IconArrowBigUp, IconArrowBigUpFilled } from "@tabler/icons-react";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import type { WorkshopBackend } from "@homarr/workshop/backend";
import { useWorkshopUserVotesQuery, useWorkshopVoteMutation } from "@homarr/workshop/backend";
interface WorkshopVoteControlProps {
client: WorkshopBackend;
submissionId: string;
score: number;
/** Voting requires a Workshop account; installing does not. */
canVote: boolean;
size?: "sm" | "md";
}
/**
* Upvote / net score / downvote. The user's own vote is filled and coloured,
* and clicking it again removes the vote.
*/
export function WorkshopVoteControl({ client, submissionId, score, canVote, size = "md" }: WorkshopVoteControlProps) {
const t = useScopedI18n("workshop");
const vote = useWorkshopVoteMutation(client);
const userVotes = useWorkshopUserVotesQuery(client);
const ownVote = userVotes.data?.find((entry) => entry.submission === submissionId)?.value ?? null;
const castVote = (value: 1 | -1) =>
vote.mutate(
{ submission: submissionId, value },
{ onError: (error) => showErrorNotification({ title: t("voteError"), message: error.message }) },
);
const iconSize = size === "sm" ? 16 : 18;
const controlSize = size === "sm" ? "sm" : "md";
const upvoted = ownVote === 1;
const downvoted = ownVote === -1;
const controls = (
<Group gap={2} wrap="nowrap">
<MaybeTooltip label={canVote ? t("upvote") : undefined}>
<ActionIcon
variant={upvoted ? "light" : "subtle"}
color={upvoted ? "red" : "gray"}
size={controlSize}
aria-label={t("upvote")}
aria-pressed={upvoted}
loading={vote.isPending && vote.variables?.value === 1}
disabled={!canVote || vote.isPending}
onClick={() => castVote(1)}
>
{upvoted ? <IconArrowBigUpFilled size={iconSize} /> : <IconArrowBigUp size={iconSize} stroke={1.5} />}
</ActionIcon>
</MaybeTooltip>
<Text
size="sm"
fw={600}
ta="center"
miw={24}
c={upvoted ? "red" : downvoted ? "blue" : undefined}
aria-label={t("score", { count: score })}
>
{score}
</Text>
<MaybeTooltip label={canVote ? t("downvote") : undefined}>
<ActionIcon
variant={downvoted ? "light" : "subtle"}
color={downvoted ? "blue" : "gray"}
size={controlSize}
aria-label={t("downvote")}
aria-pressed={downvoted}
loading={vote.isPending && vote.variables?.value === -1}
disabled={!canVote || vote.isPending}
onClick={() => castVote(-1)}
>
{downvoted ? <IconArrowBigDownFilled size={iconSize} /> : <IconArrowBigDown size={iconSize} stroke={1.5} />}
</ActionIcon>
</MaybeTooltip>
</Group>
);
// Tooltips never fire on disabled controls, so when voting is unavailable the
// hint has to hang off the enabled wrapper instead of the buttons themselves.
return canVote ? controls : <Tooltip label={t("signInHint")}>{controls}</Tooltip>;
}
const MaybeTooltip = ({ label, children }: { label: string | undefined; children: React.ReactElement }) =>
label ? <Tooltip label={label}>{children}</Tooltip> : children;