feat: simplify Workshop widget installation

This commit is contained in:
Thomas Camlong committed 2026-08-16 23:11:52 +02:00
1 parent 304ea089a2
commit 960551f573
4 files changed
+384 -222

No files matched your search

@@ -2,6 +2,7 @@
import { useEffect, useId, useMemo, useState } from "react";
import {
Accordion,
Alert,
Badge,
Box,
@@ -21,16 +22,21 @@ import {
Textarea,
TextInput,
Title,
VisuallyHidden,
} from "@mantine/core";
import type { ModalProps } from "@mantine/core";
import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
import {
IconAlertTriangle,
IconArrowBigDown,
IconArrowBigUp,
IconArrowRight,
IconBuildingStore,
IconDownload,
IconEye,
IconExternalLink,
IconFlag,
IconMessageCircle,
IconRefresh,
IconSearch,
IconThumbDown,
@@ -62,7 +68,7 @@ import {
workshopExportFilename,
} from "@homarr/workshop/schema";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useCurrentLocale, useScopedI18n } from "@homarr/translation/client";
import { createWorkshopClient, getWorkshopWebUrl } from "./workshop-client";
@@ -95,9 +101,27 @@ interface WorkshopModalStep {
close(): void;
}
const detailsModalStyles = {
content: {
display: "flex",
flexDirection: "column" as const,
height: "min(85dvh, 800px)",
},
body: {
display: "flex",
flex: 1,
flexDirection: "column" as const,
minHeight: 0,
overflow: "hidden",
padding: 0,
},
};
export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, modalStack }: WorkshopBrowserProps) {
const t = useScopedI18n("workshop");
const currentLocale = useCurrentLocale();
const client = useMemo(createWorkshopClient, []);
const dateFormatter = useMemo(() => new Intl.DateTimeFormat(currentLocale, { dateStyle: "medium" }), [currentLocale]);
const [user, setUser] = useState<WorkshopUser | null>(null);
const [page, setPage] = useState(1);
const [sort, setSort] = useState<"top" | "newest">("top");
@@ -276,45 +300,86 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, mo
) : list.data?.items.length ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
{list.data.items.map((item) => (
<Card key={item.id} withBorder radius="md" p="md">
<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={170} alt="" />
<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={170}
h={190}
bg="var(--mantine-color-default-hover)"
style={{ display: "grid", placeItems: "center" }}
>
<Code>{item.type === "customCss" ? "CSS" : "JSX"}</Code>
<Code fz="md">{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 || t("communityMember") })}
</Text>
<Text size="sm" c="dimmed">
<IconThumbUp size={13} /> {item.upvotes} · <IconThumbDown size={13} /> {item.downvotes}
<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 fw={700} lineClamp={1}>
{item.title}
</Text>
<Text size="sm" c="dimmed" lineClamp={2}>
<Text size="sm" c="dimmed" lineClamp={3}>
{item.description || t("noDescription")}
</Text>
<Button variant="light" onClick={() => openDetails(item.id)}>
{type === "customCss" ? t("inspectCss") : t("install")}
</Button>
</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>
@@ -334,161 +399,198 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, mo
stackId={modalStack?.details.stackId}
title={type === "customCss" ? t("inspectCss") : t("install")}
size={modalStack?.modalProps.size ?? "xl"}
styles={detailsModalStyles}
>
{detail.isPending ? (
<Skeleton h={400} />
<Box p="lg">
<Skeleton h={400} />
</Box>
) : detail.isError || !detail.data ? (
<Alert color="red">{detail.error instanceof Error ? detail.error.message : t("loadError")}</Alert>
<Box p="lg">
<Alert color="red">{detail.error instanceof Error ? detail.error.message : t("loadError")}</Alert>
</Box>
) : (
<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="default"
leftSection={<IconExternalLink size={16} />}
>
{t("openCommunity")}
</Button>
</Group>
{detail.data.description && <Text>{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}
<>
<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>
</Card>
))}
{reportSummaries.data?.length === 0 && (
</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("reportVisibility")}
{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>
)}
{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>
)}
{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>
)}
<WorkshopCodeViewer value={detail.data.content} language={type === "customCss" ? "css" : "json"} />
<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}
aria-label={t("upvote", { count: detail.data.upvotes })}
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}
aria-label={t("downvote", { count: detail.data.downvotes })}
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={openReport}
>
{t("report")}
</Button>
</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>
<Button
variant="subtle"
size="compact-sm"
leftSection={<IconThumbUp size={16} />}
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 })}
>
{detail.data.upvotes}
</Button>
<Button
variant="subtle"
size="compact-sm"
leftSection={<IconThumbDown size={16} />}
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 })}
>
{detail.data.downvotes}
</Button>
<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>
{onInstall && (
<Button
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
loading={useCss.isPending}
disabled={!detailCompatible}
onClick={() =>
detailValidation?.success &&
typeof detailValidation.data === "string" &&
useCss.mutate(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>
</Box>
</>
)}
</Modal>
@@ -25,6 +25,21 @@ const installFlowModalProps = {
},
} 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"]);
@@ -74,7 +89,7 @@ export function WorkshopInstallButton({ children, fullWidth }: WorkshopInstallBu
<CustomWidgetImportDialog
{...stack.register("review")}
widget={pendingWidget}
modalProps={installFlowModalProps}
modalProps={installReviewModalProps}
labels={{
title: t("confirmInstallTitle"),
description: t("confirmInstallDescription"),
@@ -1,5 +1,17 @@
import type { ReactNode } from "react";
import { Alert, Badge, Button, Code, Group, Modal, Paper, SimpleGrid, Stack, Text } from "@mantine/core";
import {
Alert,
Badge,
Box,
Button,
Code,
Group,
Modal,
Paper,
SimpleGrid,
Stack,
Text,
} from "@mantine/core";
import type { ModalProps } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
@@ -36,6 +48,22 @@ export interface ImportReviewDialogProps {
onConfirm(): void;
}
const importReviewModalStyles = {
content: {
display: "flex",
flexDirection: "column" as const,
maxHeight: "min(85dvh, 900px)",
},
body: {
display: "flex",
flex: 1,
flexDirection: "column" as const,
minHeight: 0,
overflow: "hidden",
padding: 0,
},
};
function ImportFact({ label, value }: { label: string; value: string }) {
return (
<Paper withBorder p="xs">
@@ -70,65 +98,78 @@ export function ImportReviewDialog({
stackId={stackId}
zIndex={zIndex}
size={size ?? "lg"}
styles={styles}
styles={styles ?? importReviewModalStyles}
>
{review && (
<Stack gap="md">
<Text size="sm" c="dimmed">
{messages.description}
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xs">
<ImportFact label={messages.name} value={review.name} />
<ImportFact label={messages.origin} value={review.origins.join(", ")} />
<ImportFact label={messages.authentication} value={review.authTypes.join(", ")} />
<ImportFact label={messages.networkScope} value={review.networkScopes.join(", ")} />
</SimpleGrid>
<div>
<Text size="sm" fw={600} mb={6}>
{messages.methods}
</Text>
<Group gap={6}>
{review.methods.map((method) => (
<Badge
key={method}
color={method === "DELETE" ? "red" : method === "GET" ? "blue" : "orange"}
variant="light"
>
{method}
</Badge>
))}
</Group>
</div>
<div>
<Text size="sm" fw={600} mb={6}>
{messages.permissions}
</Text>
<Group gap={6}>
{review.permissions.map((permission) => (
<Badge key={permission} color="gray" variant="light">
{messages.permission(permission)}
</Badge>
))}
</Group>
</div>
{review.hasActions && (
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
<Text size="sm" fw={600}>
{messages.actionWarningTitle}
<>
<Box p="lg" style={{ flex: 1, minHeight: 0, overflowY: "auto" }}>
<Stack gap="md">
<Text size="sm" c="dimmed">
{messages.description}
</Text>
<Text size="sm">{messages.actionWarningDescription}</Text>
</Alert>
)}
{children}
<Group justify="flex-end">
<Button type="button" variant="default" onClick={onClose} disabled={pending}>
{messages.cancel}
</Button>
<Button type="button" onClick={onConfirm} loading={pending} disabled={confirmDisabled}>
{messages.confirm}
</Button>
</Group>
</Stack>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xs">
<ImportFact label={messages.name} value={review.name} />
<ImportFact label={messages.origin} value={review.origins.join(", ")} />
<ImportFact label={messages.authentication} value={review.authTypes.join(", ")} />
<ImportFact label={messages.networkScope} value={review.networkScopes.join(", ")} />
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<div>
<Text size="sm" fw={600} mb={6}>
{messages.methods}
</Text>
<Group gap={6}>
{review.methods.map((method) => (
<Badge
key={method}
color={method === "DELETE" ? "red" : method === "GET" ? "blue" : "orange"}
variant="light"
>
{method}
</Badge>
))}
</Group>
</div>
<div>
<Text size="sm" fw={600} mb={6}>
{messages.permissions}
</Text>
<Group gap={6}>
{review.permissions.map((permission) => (
<Badge key={permission} color="gray" variant="light">
{messages.permission(permission)}
</Badge>
))}
</Group>
</div>
</SimpleGrid>
{review.hasActions && (
<Alert color="yellow" icon={<IconAlertTriangle size={16} />}>
<Text size="sm" fw={600}>
{messages.actionWarningTitle}
</Text>
<Text size="sm">{messages.actionWarningDescription}</Text>
</Alert>
)}
{children}
</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="flex-end" gap="sm">
<Button type="button" variant="default" onClick={onClose} disabled={pending}>
{messages.cancel}
</Button>
<Button type="button" onClick={onConfirm} loading={pending} disabled={confirmDisabled}>
{messages.confirm}
</Button>
</Group>
</Box>
</>
)}
</Modal>
);
+4
View File
@@ -1407,6 +1407,9 @@
"noDescription": "No description",
"author": "by {name}",
"communityMember": "Community member",
"comments": "{count, plural, one {# comment} other {# comments}}",
"viewDetails": "View details",
"voteSummary": "{upvotes, plural, one {# upvote} other {# upvotes}}, {downvotes, plural, one {# downvote} other {# downvotes}}",
"outdated": "Outdated",
"outdatedWarning": "The author marked this submission as outdated. Review it carefully before importing.",
"reportCount": "{count, plural, one {# report} other {# reports}}",
@@ -1416,6 +1419,7 @@
"upvote": "Upvote, {count, plural, one {# vote} other {# votes}}",
"downvote": "Downvote, {count, plural, one {# vote} other {# votes}}",
"inspectCss": "Inspect CSS",
"technicalDetails": "Technical details",
"sourceCode": "Workshop entry",
"loadError": "This submission could not be loaded.",
"sources": "Declared API sources",