feat: community workshop for custom widgets and CSS (#5984)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
1 parent
121a83040d
commit
c69e33ac8c
58 files changed
+4475
-112
No files matched your search
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconUsersGroup } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { customWidgetImportSchema } from "@homarr/validation/custom-widget";
|
||||
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { WorkshopBrowserModal } from "./workshop-browser-modal";
|
||||
|
||||
function parseWidgetContent(content: string) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(content);
|
||||
} catch {
|
||||
return { ok: false as const, message: "Submission content is not valid JSON" };
|
||||
}
|
||||
const result = customWidgetImportSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
return { ok: false as const, message: result.error.issues[0]?.message ?? "Invalid widget schema" };
|
||||
}
|
||||
return { ok: true as const, data: result.data };
|
||||
}
|
||||
|
||||
export const InstallWidgetFromWorkshopButton = () => {
|
||||
const t = useScopedI18n("customWidget.workshop");
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||
const utils = clientApi.useUtils();
|
||||
const importMutation = clientApi.customWidget.import.useMutation({
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({ title: t("title"), message: t("notification.widgetInstalled") });
|
||||
void utils.customWidget.all.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets");
|
||||
setPendingId(null);
|
||||
close();
|
||||
},
|
||||
onError: (err) => {
|
||||
showErrorNotification({ title: t("title"), message: err.message || t("notification.widgetInstallFailed") });
|
||||
setPendingId(null);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSelect = (submission: { id: string; content: string }) => {
|
||||
const parsed = parseWidgetContent(submission.content);
|
||||
if (!parsed.ok) {
|
||||
showErrorNotification({ title: t("title"), message: parsed.message });
|
||||
return;
|
||||
}
|
||||
setPendingId(submission.id);
|
||||
importMutation.mutate(parsed.data);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MobileAffixButton variant="default" leftSection={<IconUsersGroup size={16} />} onClick={open}>
|
||||
{t("action.installFromWorkshop")}
|
||||
</MobileAffixButton>
|
||||
<WorkshopBrowserModal
|
||||
type="widget"
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
actionLabel={t("action.install")}
|
||||
pendingId={pendingId}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const InstallCssFromWorkshopButton = ({ onSelect }: { onSelect: (css: string) => void }) => {
|
||||
const t = useScopedI18n("customWidget.workshop");
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="default" leftSection={<IconUsersGroup size={16} />} onClick={open}>
|
||||
{t("action.installFromWorkshop")}
|
||||
</Button>
|
||||
<WorkshopBrowserModal
|
||||
type="css"
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
actionLabel={t("action.use")}
|
||||
onSelect={(submission) => {
|
||||
onSelect(submission.content);
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
export const WORKSHOP_API_URL = process.env.NEXT_PUBLIC_WORKSHOP_API_URL || "https://workshop.homarr.dev";
|
||||
|
||||
export interface WorkshopSubmission {
|
||||
id: string;
|
||||
collectionId: string;
|
||||
collectionName: string;
|
||||
type: "css" | "widget";
|
||||
title: string;
|
||||
description: string;
|
||||
schemaVersion: string;
|
||||
content: string;
|
||||
screenshots: string[];
|
||||
upvotes: number;
|
||||
downvotes: number;
|
||||
version: number;
|
||||
author: string;
|
||||
authorName: string;
|
||||
commentCount: number;
|
||||
created: string;
|
||||
}
|
||||
|
||||
interface PBListResponse {
|
||||
page: number;
|
||||
perPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
items: WorkshopSubmission[];
|
||||
}
|
||||
|
||||
export const getWorkshopFileUrl = (submission: WorkshopSubmission, filename: string) =>
|
||||
`${WORKSHOP_API_URL}/api/files/submissions/${submission.id}/${encodeURIComponent(filename)}`;
|
||||
|
||||
export const fetchWorkshopSubmissions = async (
|
||||
type: "css" | "widget",
|
||||
page = 1,
|
||||
perPage = 50,
|
||||
): Promise<PBListResponse> => {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
perPage: String(perPage),
|
||||
sort: "-upvotes",
|
||||
filter: `(type='${type}')`,
|
||||
});
|
||||
const response = await fetch(`${WORKSHOP_API_URL}/api/collections/marketplace/records?${params}`);
|
||||
if (!response.ok) throw new Error(`Workshop request failed (${response.status})`);
|
||||
return response.json() as Promise<PBListResponse>;
|
||||
};
|
||||
@@ -0,0 +1,221 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
Image,
|
||||
Loader,
|
||||
Modal,
|
||||
Pagination,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { IconChevronLeft, IconChevronRight, IconExternalLink } from "@tabler/icons-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import type { WorkshopSubmission } from "./workshop-api";
|
||||
import { fetchWorkshopSubmissions, getWorkshopFileUrl } from "./workshop-api";
|
||||
|
||||
const WORKSHOP_URL = process.env.NEXT_PUBLIC_WORKSHOP_URL || "https://homarr.dev/workshop";
|
||||
|
||||
interface Props {
|
||||
type: "css" | "widget";
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (submission: WorkshopSubmission) => void;
|
||||
actionLabel: string;
|
||||
pendingId?: string | null;
|
||||
}
|
||||
|
||||
const displayName = (submission: WorkshopSubmission) => submission.authorName;
|
||||
|
||||
export const WorkshopBrowserModal = ({ type, opened, onClose, onSelect, actionLabel, pendingId }: Props) => {
|
||||
const [page, setPage] = useState(1);
|
||||
const handleClose = () => {
|
||||
setPage(1);
|
||||
onClose();
|
||||
};
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["workshop-submissions", type, page],
|
||||
queryFn: () => fetchWorkshopSubmissions(type, page),
|
||||
enabled: opened,
|
||||
});
|
||||
|
||||
const errorText = error instanceof Error ? error.message : "unknown error";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={
|
||||
<Group gap="sm">
|
||||
<Text fw={600}>Install from Workshop</Text>
|
||||
<Anchor
|
||||
href={WORKSHOP_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="xs"
|
||||
style={{ display: "inline-flex", alignItems: "center", gap: 4 }}
|
||||
>
|
||||
Open Workshop <IconExternalLink size={12} />
|
||||
</Anchor>
|
||||
</Group>
|
||||
}
|
||||
size={1200}
|
||||
scrollAreaComponent={ScrollArea.Autosize}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{isLoading && (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{error && <Alert color="red">Failed to load the workshop: {errorText}</Alert>}
|
||||
|
||||
{data && data.items.length === 0 && (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
No submissions available yet.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{data && data.items.length > 0 && (
|
||||
<>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{data.items.map((submission) => (
|
||||
<SubmissionCard
|
||||
key={submission.id}
|
||||
submission={submission}
|
||||
actionLabel={actionLabel}
|
||||
pending={pendingId === submission.id}
|
||||
onSelect={() => onSelect(submission)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{data.totalPages > 1 && (
|
||||
<Center>
|
||||
<Pagination total={data.totalPages} value={page} onChange={setPage} />
|
||||
</Center>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const SubmissionCard = ({
|
||||
submission,
|
||||
actionLabel,
|
||||
pending,
|
||||
onSelect,
|
||||
}: {
|
||||
submission: WorkshopSubmission;
|
||||
actionLabel: string;
|
||||
pending: boolean;
|
||||
onSelect: () => void;
|
||||
}) => {
|
||||
const screenshots = submission.screenshots ?? [];
|
||||
const [imgIdx, setImgIdx] = useState(0);
|
||||
const score = submission.upvotes - submission.downvotes;
|
||||
const scorePrefix = score > 0 ? "+" : "";
|
||||
|
||||
return (
|
||||
<Card withBorder padding={0}>
|
||||
{screenshots.length > 0 && (
|
||||
<Box pos="relative">
|
||||
<Image
|
||||
src={screenshots[imgIdx] ? getWorkshopFileUrl(submission, screenshots[imgIdx]) : undefined}
|
||||
alt={submission.title}
|
||||
h={180}
|
||||
fit="cover"
|
||||
/>
|
||||
{screenshots.length > 1 && (
|
||||
<>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="dark"
|
||||
size="xs"
|
||||
pos="absolute"
|
||||
top="50%"
|
||||
left={4}
|
||||
style={{ transform: "translateY(-50%)" }}
|
||||
onClick={() => setImgIdx((i) => (i - 1 + screenshots.length) % screenshots.length)}
|
||||
>
|
||||
<IconChevronLeft size={12} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="dark"
|
||||
size="xs"
|
||||
pos="absolute"
|
||||
top="50%"
|
||||
right={4}
|
||||
style={{ transform: "translateY(-50%)" }}
|
||||
onClick={() => setImgIdx((i) => (i + 1) % screenshots.length)}
|
||||
>
|
||||
<IconChevronRight size={12} />
|
||||
</ActionIcon>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Stack gap={4} p="sm">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Box
|
||||
w={8}
|
||||
h={8}
|
||||
bg={submission.type === "css" ? "blue.5" : "yellow.5"}
|
||||
style={{ borderRadius: "50%", flexShrink: 0 }}
|
||||
/>
|
||||
<Text fw={600} size="sm" truncate style={{ flex: 1 }}>
|
||||
{submission.title}
|
||||
</Text>
|
||||
<Badge variant="light" size="sm" style={{ flexShrink: 0 }}>
|
||||
{scorePrefix}
|
||||
{score}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Text size="xs" c="dimmed">
|
||||
{displayName(submission)} · v{submission.version}
|
||||
</Text>
|
||||
|
||||
{submission.description && (
|
||||
<Text size="xs" c="dimmed" lineClamp={2}>
|
||||
{submission.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Group gap="xs" mt={4}>
|
||||
<Button size="xs" style={{ flex: 1 }} loading={pending} onClick={onSelect}>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
size="sm"
|
||||
component="a"
|
||||
href={`${WORKSHOP_URL}/${submission.id}/`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="View in workshop"
|
||||
>
|
||||
<IconExternalLink size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user