feat(custom-widgets): complete Custom JSX v2 (#6390)
This commit is contained in:
1 parent
3a1245cbc5
commit
acdfcbb18e
416 files changed
+37853
-18066
No files matched your search
@@ -1,21 +1,17 @@
|
||||
import type { ComponentType, MutableRefObject } from "react";
|
||||
import { Suspense, use, useRef } from "react";
|
||||
import type { CardProps } from "@mantine/core";
|
||||
import { Badge, Card, Center, Loader } from "@mantine/core";
|
||||
import type { MutableRefObject } from "react";
|
||||
import { useRef } from "react";
|
||||
import { Badge, Card } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
|
||||
import combineClasses from "clsx";
|
||||
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
|
||||
import type { FallbackProps } from "react-error-boundary";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
import { getWidgetQueryKeys } from "@homarr/widgets/definition";
|
||||
import type { WidgetComponentProps, WidgetDefinition } from "@homarr/widgets/definition";
|
||||
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import classes from "../sections/item.module.css";
|
||||
@@ -23,7 +19,6 @@ import { useItemActions } from "./item-actions";
|
||||
import itemContentClasses from "./item-content.module.css";
|
||||
import { BoardItemMenu } from "./item-menu";
|
||||
import { WidgetContextMenu } from "./widget-context-menu";
|
||||
import { removeWidgetDataQueries } from "./widget-query-recovery";
|
||||
|
||||
interface BoardItemContentProps {
|
||||
item: SectionItem;
|
||||
@@ -35,53 +30,6 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
type BoardItemCardProps = CardProps & {
|
||||
item: SectionItem;
|
||||
innerRef: (element: HTMLDivElement | null) => void;
|
||||
};
|
||||
|
||||
const BoardItemCard = ({ item, innerRef, children, ...cardProps }: BoardItemCardProps) => {
|
||||
const board = useRequiredBoard();
|
||||
|
||||
return (
|
||||
<Card
|
||||
{...cardProps}
|
||||
ref={innerRef}
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
`${item.kind}-wrapper`,
|
||||
"grid-stack-item-content",
|
||||
item.advancedOptions.customCssClasses.join(" "),
|
||||
)}
|
||||
radius={board.itemRadius}
|
||||
styles={{
|
||||
root: {
|
||||
"--opacity": board.opacity / 100,
|
||||
containerType: "size",
|
||||
overflow: getOverflowFromKind(item.kind),
|
||||
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
||||
},
|
||||
}}
|
||||
p={0}
|
||||
>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const WidgetDefinitionLoadError = ({
|
||||
error,
|
||||
resetErrorBoundary,
|
||||
item,
|
||||
innerRef,
|
||||
}: FallbackProps & Pick<BoardItemCardProps, "item" | "innerRef">) => (
|
||||
<BoardItemCard item={item} innerRef={innerRef}>
|
||||
<Center h="100%">
|
||||
<WidgetError error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
</Center>
|
||||
</BoardItemCard>
|
||||
);
|
||||
|
||||
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
const { ref, width, height } = useElementSize<HTMLDivElement>();
|
||||
const board = useRequiredBoard();
|
||||
@@ -89,28 +37,29 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<ErrorBoundary
|
||||
resetKeys={[item.kind]}
|
||||
fallbackRender={(fallbackProps) => <WidgetDefinitionLoadError {...fallbackProps} item={item} innerRef={ref} />}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<BoardItemCard item={item} innerRef={ref}>
|
||||
<Center h="100%">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
</BoardItemCard>
|
||||
}
|
||||
<WidgetContextMenu item={item} widgetStateRef={widgetStateRef}>
|
||||
<Card
|
||||
ref={ref}
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
`${item.kind}-wrapper`,
|
||||
"grid-stack-item-content",
|
||||
item.advancedOptions.customCssClasses.join(" "),
|
||||
)}
|
||||
radius={board.itemRadius}
|
||||
styles={{
|
||||
root: {
|
||||
"--opacity": board.opacity / 100,
|
||||
containerType: "size",
|
||||
overflow: getOverflowFromKind(item.kind),
|
||||
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
||||
},
|
||||
}}
|
||||
p={0}
|
||||
>
|
||||
<LoadedBoardItemContent
|
||||
item={item}
|
||||
width={width}
|
||||
height={height}
|
||||
widgetStateRef={widgetStateRef}
|
||||
innerRef={ref}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
<InnerContent item={item} width={width} height={height} widgetStateRef={widgetStateRef} />
|
||||
</Card>
|
||||
</WidgetContextMenu>
|
||||
{item.advancedOptions.title?.trim() && (
|
||||
<Badge
|
||||
pos="absolute"
|
||||
@@ -136,72 +85,40 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
interface LoadedBoardItemContentProps extends Omit<InnerContentProps, "definition" | "Component"> {
|
||||
innerRef: (element: HTMLDivElement | null) => void;
|
||||
}
|
||||
|
||||
const LoadedBoardItemContent = ({ item, innerRef, ...innerContentProps }: LoadedBoardItemContentProps) => {
|
||||
const { definition, Component } = use(loadWidgetResources(item.kind));
|
||||
|
||||
return (
|
||||
<WidgetContextMenu item={item} definition={definition} widgetStateRef={innerContentProps.widgetStateRef}>
|
||||
<BoardItemCard item={item} innerRef={innerRef}>
|
||||
<ErrorBoundary
|
||||
resetKeys={[item.kind]}
|
||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||
<>
|
||||
<BoardItemMenu offset={4} item={item} definition={definition} resetErrorBoundary={resetErrorBoundary} />
|
||||
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<InnerContent item={item} definition={definition} Component={Component} {...innerContentProps} />
|
||||
</ErrorBoundary>
|
||||
</BoardItemCard>
|
||||
</WidgetContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
interface InnerContentProps {
|
||||
item: SectionItem;
|
||||
width: number;
|
||||
height: number;
|
||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
||||
definition: WidgetDefinition;
|
||||
Component: ComponentType<WidgetComponentProps<SectionItem["kind"]>>;
|
||||
}
|
||||
|
||||
const InnerContent = ({ item, definition, Component, ...dimensions }: InnerContentProps) => {
|
||||
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
const settings = useSettings();
|
||||
const board = useRequiredBoard();
|
||||
const [isEditMode] = useEditMode();
|
||||
const options = reduceWidgetOptionsWithDefinition(definition, settings, item.options);
|
||||
const Comp = loadWidgetDynamic(item.kind);
|
||||
const { definition } = widgetImports[item.kind];
|
||||
const options = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
|
||||
const newItem = { ...item, options };
|
||||
const { updateItemOptions } = useItemActions();
|
||||
const { removeItem, updateItemOptions } = useItemActions();
|
||||
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
|
||||
updateItemOptions({ itemId: item.id, newOptions });
|
||||
const widgetSupportsIntegrations =
|
||||
"supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) >= 1;
|
||||
"supportedIntegrations" in definition && definition.supportedIntegrations.length >= 1;
|
||||
const queryClient = useQueryClient();
|
||||
const widgetQueryKeys = getWidgetQueryKeys(definition, item.kind);
|
||||
|
||||
return (
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={() => {
|
||||
removeWidgetDataQueries(queryClient, widgetQueryKeys);
|
||||
void queryClient.invalidateQueries({ queryKey: [["widget"]] });
|
||||
reset();
|
||||
}}
|
||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||
<>
|
||||
<BoardItemMenu
|
||||
offset={4}
|
||||
item={newItem}
|
||||
definition={definition}
|
||||
resetErrorBoundary={resetErrorBoundary}
|
||||
/>
|
||||
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
<BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />
|
||||
<WidgetError kind={item.kind} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
</>
|
||||
)}
|
||||
>
|
||||
@@ -213,25 +130,24 @@ const InnerContent = ({ item, definition, Component, ...dimensions }: InnerConte
|
||||
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false)
|
||||
}
|
||||
/>
|
||||
<BoardItemMenu offset={4} item={newItem} definition={definition} />
|
||||
<div data-homarr-widget-ready={item.kind} style={{ display: "contents" }}>
|
||||
<Component
|
||||
options={options as never}
|
||||
integrationIds={item.integrationIds}
|
||||
isEditMode={isEditMode}
|
||||
boardId={board.id}
|
||||
itemId={item.id}
|
||||
setOptions={(partialNewOptions) =>
|
||||
updateOptions({
|
||||
newOptions: {
|
||||
...options,
|
||||
...partialNewOptions.newOptions,
|
||||
},
|
||||
})
|
||||
}
|
||||
{...dimensions}
|
||||
/>
|
||||
</div>
|
||||
<BoardItemMenu offset={4} item={newItem} />
|
||||
<Comp
|
||||
options={options as never}
|
||||
integrationIds={item.integrationIds}
|
||||
isEditMode={isEditMode}
|
||||
boardId={board.id}
|
||||
itemId={item.id}
|
||||
removeItem={() => removeItem({ itemId: item.id })}
|
||||
setOptions={(partialNewOptions) =>
|
||||
updateOptions({
|
||||
newOptions: {
|
||||
...options,
|
||||
...partialNewOptions.newOptions,
|
||||
},
|
||||
})
|
||||
}
|
||||
{...dimensions}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>
|
||||
|
||||
@@ -1,65 +1,47 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { IconApi } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createId } from "@homarr/common";
|
||||
import { getIconUrl, getIntegrationName, widgetIntegrationSupport, widgetKinds } from "@homarr/definitions";
|
||||
import { createId, objectEntries } from "@homarr/common";
|
||||
import { getIconUrl, getIntegrationName } from "@homarr/definitions";
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||
|
||||
import { WorkshopInstallButton } from "~/components/workshop/workshop-install-button";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import { resolveMatchingIntegrationsAsync, tryLockSelection, unlockSelection } from "./item-select-data";
|
||||
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
|
||||
import classes from "./item-select-modal.module.css";
|
||||
|
||||
interface ItemSelectModalContentProps {
|
||||
actions: { closeModal: () => void };
|
||||
innerProps: void;
|
||||
integrationData: RouterOutputs["integration"]["all"] | undefined;
|
||||
customWidgetDefs: RouterOutputs["customWidget"]["all"] | undefined;
|
||||
ensureIntegrationDataAsync: () => Promise<RouterOutputs["integration"]["all"]>;
|
||||
}
|
||||
|
||||
const ItemSelectModalContent = ({
|
||||
actions,
|
||||
integrationData,
|
||||
customWidgetDefs,
|
||||
ensureIntegrationDataAsync,
|
||||
}: ItemSelectModalContentProps) => {
|
||||
export const ItemSelectModal = createModal<{ boardId: string }>(({ actions, innerProps }) => {
|
||||
const [search, setSearch] = useState("");
|
||||
const [loadingSelection, setLoadingSelection] = useState<string | null>(null);
|
||||
const selectionLock = useRef(false);
|
||||
const t = useI18n();
|
||||
const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
|
||||
const { openModal: openEditModal } = useModalAction(LazyWidgetEditModal);
|
||||
const { openModal: openEditModal } = useModalAction(WidgetEditModal);
|
||||
const { data: integrationData } = clientApi.integration.all.useQuery();
|
||||
const { data: customWidgetDefs } = clientApi.customWidget.available.useQuery({ boardId: innerProps.boardId });
|
||||
const settings = useSettings();
|
||||
|
||||
const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]);
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
widgetKinds
|
||||
.filter((kind) => kind !== "customApi")
|
||||
.map((kind) => {
|
||||
return {
|
||||
kind,
|
||||
supportedIntegrations: (widgetIntegrationSupport[kind] ?? []).filter(
|
||||
(integration) => integration !== "mock",
|
||||
),
|
||||
icon: widgetCatalogIcons[kind],
|
||||
name: t(`widget.${kind}.name`),
|
||||
description: t(`widget.${kind}.description`),
|
||||
};
|
||||
})
|
||||
objectEntries(widgetImports)
|
||||
.filter(([kind]) => kind !== "customApi")
|
||||
.map(([kind, value]) => ({
|
||||
kind,
|
||||
supportedIntegrations:
|
||||
"supportedIntegrations" in value.definition
|
||||
? value.definition.supportedIntegrations.filter((integration) => integration !== "mock")
|
||||
: [],
|
||||
icon: value.definition.icon,
|
||||
name: t(`widget.${kind}.name`),
|
||||
description: t(`widget.${kind}.description`),
|
||||
}))
|
||||
.sort((itemA, itemB) => {
|
||||
if (itemA.kind === "app") return -1;
|
||||
if (itemB.kind === "app") return 1;
|
||||
@@ -80,86 +62,81 @@ const ItemSelectModalContent = ({
|
||||
}, [items, search]);
|
||||
|
||||
const filteredCustomWidgets = useMemo(
|
||||
() =>
|
||||
(customWidgetDefs ?? []).filter((def) => def.enabled && def.name.toLowerCase().includes(search.toLowerCase())),
|
||||
() => (customWidgetDefs ?? []).filter((def) => def.name.toLowerCase().includes(search.toLowerCase())),
|
||||
[customWidgetDefs, search],
|
||||
);
|
||||
|
||||
const notifyDefinitionLoadError = (error: unknown) => {
|
||||
showErrorNotification({
|
||||
title: t("common.error"),
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
const handleAddCustomWidget = (definition: NonNullable<typeof customWidgetDefs>[number]) => {
|
||||
const itemId = createId();
|
||||
const defaultOptions = reduceWidgetOptionsWithDefaultValues("customApi", settings);
|
||||
const options = {
|
||||
...defaultOptions,
|
||||
definitionId: definition.id,
|
||||
configuration: definition.defaultOptions,
|
||||
configurationVersion: definition.updatedAt.getTime(),
|
||||
};
|
||||
actions.closeModal();
|
||||
openEditModal(
|
||||
{
|
||||
kind: "customApi",
|
||||
value: {
|
||||
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
||||
options,
|
||||
integrationIds: [],
|
||||
},
|
||||
onSuccessfulEdit: ({ options: configuredOptions, advancedOptions }) => {
|
||||
createItem({ id: itemId, kind: "customApi", integrationIds: [] });
|
||||
updateItemOptions({ itemId, newOptions: configuredOptions });
|
||||
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
|
||||
},
|
||||
integrationData: [],
|
||||
integrationSupport: false,
|
||||
settings,
|
||||
},
|
||||
{
|
||||
title: (titleT) => `${titleT("item.edit.title")} - ${definition.name}`,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddCustomWidget = async (definitionId: string) => {
|
||||
if (!tryLockSelection(selectionLock)) return;
|
||||
setLoadingSelection(`custom:${definitionId}`);
|
||||
try {
|
||||
const definition = await loadWidgetDefinition("customApi");
|
||||
const itemId = createId();
|
||||
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
|
||||
createItem({ id: itemId, kind: "customApi", integrationIds: [] });
|
||||
updateItemOptions({ itemId, newOptions: { ...defaultOptions, definitionId } });
|
||||
actions.closeModal();
|
||||
} catch (error) {
|
||||
notifyDefinitionLoadError(error);
|
||||
} finally {
|
||||
unlockSelection(selectionLock);
|
||||
setLoadingSelection(null);
|
||||
}
|
||||
};
|
||||
const handleAdd = (kind: WidgetKind) => {
|
||||
const definition = widgetImports[kind].definition;
|
||||
const hasIntegrationSupport = "supportedIntegrations" in definition;
|
||||
|
||||
const handleAdd = async (kind: WidgetKind) => {
|
||||
if (!tryLockSelection(selectionLock)) return;
|
||||
setLoadingSelection(kind);
|
||||
preloadWidgetEditModal();
|
||||
try {
|
||||
const definition = await loadWidgetDefinition(kind);
|
||||
const hasIntegrationSupport = "supportedIntegrations" in definition;
|
||||
const matchingIntegrations = hasIntegrationSupport
|
||||
? (integrationData ?? []).filter((integration) =>
|
||||
(definition.supportedIntegrations as string[]).includes(integration.kind),
|
||||
)
|
||||
: [];
|
||||
|
||||
const matchingIntegrations = await resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport,
|
||||
supportedIntegrations: definition.supportedIntegrations ?? [],
|
||||
currentData: integrationData,
|
||||
ensureDataAsync: ensureIntegrationDataAsync,
|
||||
});
|
||||
const integrationIds = matchingIntegrations.map((i) => i.id);
|
||||
const itemId = createId();
|
||||
const defaultOptions = reduceWidgetOptionsWithDefaultValues(kind, settings);
|
||||
|
||||
const integrationIds = matchingIntegrations.map((i) => i.id);
|
||||
const itemId = createId();
|
||||
const defaultOptions = reduceWidgetOptionsWithDefinition(definition, settings);
|
||||
createItem({ id: itemId, kind, integrationIds });
|
||||
actions.closeModal();
|
||||
|
||||
createItem({ id: itemId, kind, integrationIds });
|
||||
actions.closeModal();
|
||||
|
||||
openEditModal(
|
||||
{
|
||||
kind,
|
||||
definition,
|
||||
value: {
|
||||
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
||||
options: defaultOptions,
|
||||
integrationIds,
|
||||
},
|
||||
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
|
||||
updateItemOptions({ itemId, newOptions: options });
|
||||
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
|
||||
updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds });
|
||||
},
|
||||
integrationData: matchingIntegrations,
|
||||
integrationSupport: hasIntegrationSupport,
|
||||
settings,
|
||||
openEditModal(
|
||||
{
|
||||
kind,
|
||||
value: {
|
||||
advancedOptions: { title: null, customCssClasses: [], borderColor: "" },
|
||||
options: defaultOptions,
|
||||
integrationIds,
|
||||
},
|
||||
{
|
||||
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
|
||||
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
|
||||
updateItemOptions({ itemId, newOptions: options });
|
||||
updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
|
||||
updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds });
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
notifyDefinitionLoadError(error);
|
||||
} finally {
|
||||
unlockSelection(selectionLock);
|
||||
setLoadingSelection(null);
|
||||
}
|
||||
integrationData: matchingIntegrations,
|
||||
integrationSupport: hasIntegrationSupport,
|
||||
settings,
|
||||
},
|
||||
{
|
||||
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -168,9 +145,9 @@ const ItemSelectModalContent = ({
|
||||
onSearchChange={setSearch}
|
||||
placeholder={`${t("item.create.search")}...`}
|
||||
onSearchKeyDown={(event) => {
|
||||
if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) {
|
||||
const [item] = filteredItems;
|
||||
if (item) void handleAdd(item.kind);
|
||||
if (event.key === "Enter" && filteredItems.length === 1) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
handleAdd(filteredItems[0]!.kind);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -178,76 +155,64 @@ const ItemSelectModalContent = ({
|
||||
<WidgetItem
|
||||
key={item.kind}
|
||||
item={item}
|
||||
onSelect={() => void handleAdd(item.kind)}
|
||||
onIntent={() => {
|
||||
void loadWidgetDefinition(item.kind).catch(() => undefined);
|
||||
preloadWidgetEditModal();
|
||||
}}
|
||||
disabled={loadingSelection !== null}
|
||||
loading={loadingSelection === item.kind}
|
||||
onSelect={() => handleAdd(item.kind)}
|
||||
hasMatchingIntegration={item.supportedIntegrations.some((kind) => availableKinds.has(kind))}
|
||||
/>
|
||||
))}
|
||||
|
||||
{filteredCustomWidgets.length > 0 && (
|
||||
<>
|
||||
<Divider
|
||||
label={t("customWidget.page.list.title")}
|
||||
labelPosition="center"
|
||||
my="sm"
|
||||
style={{ gridColumn: "1 / -1" }}
|
||||
/>
|
||||
{filteredCustomWidgets.map((def) => (
|
||||
<Card
|
||||
key={def.id}
|
||||
className={classes.card}
|
||||
h={selectGridCardHeight}
|
||||
withBorder
|
||||
pos="relative"
|
||||
style={{ overflow: "hidden" }}
|
||||
onPointerEnter={() => void loadWidgetDefinition("customApi").catch(() => undefined)}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
{def.iconUrl ? (
|
||||
<Image src={def.iconUrl} w={22} h={22} fit="contain" style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
) : (
|
||||
<IconApi size={22} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
)}
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
|
||||
{def.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
|
||||
{def.description ?? ""}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Box
|
||||
className={classes.action}
|
||||
pos="absolute"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
p="xs"
|
||||
style={{
|
||||
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={() => void handleAddCustomWidget(def.id)}
|
||||
variant="light"
|
||||
size="xs"
|
||||
fullWidth
|
||||
disabled={loadingSelection !== null}
|
||||
loading={loadingSelection === `custom:${def.id}`}
|
||||
>
|
||||
{t("item.create.addToBoard")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<Divider
|
||||
label={t("customWidget.page.list.title")}
|
||||
labelPosition="center"
|
||||
my="sm"
|
||||
style={{ gridColumn: "1 / -1" }}
|
||||
/>
|
||||
{filteredCustomWidgets.map((def) => (
|
||||
<Card
|
||||
key={def.id}
|
||||
h={selectGridCardHeight}
|
||||
withBorder
|
||||
pos="relative"
|
||||
style={{ overflow: "hidden", "--_hover-opacity": "0" }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
|
||||
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
{def.iconUrl ? (
|
||||
<Image src={def.iconUrl} w={22} h={22} fit="contain" style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
) : (
|
||||
<IconApi size={22} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
)}
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
|
||||
{def.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
|
||||
{def.description ?? ""}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Box
|
||||
pos="absolute"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
p="xs"
|
||||
style={{
|
||||
opacity: "var(--_hover-opacity)",
|
||||
transition: "opacity 150ms ease",
|
||||
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
|
||||
}}
|
||||
>
|
||||
<Button onClick={() => handleAddCustomWidget(def)} variant="light" size="xs" fullWidth>
|
||||
{t("item.create.addToBoard")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Box style={{ gridColumn: "1 / -1" }}>
|
||||
<WorkshopInstallButton fullWidth>{t("workshop.installDialog")}</WorkshopInstallButton>
|
||||
</Box>
|
||||
|
||||
{filteredItems.length === 0 && filteredCustomWidgets.length === 0 && (
|
||||
<Center p="xl">
|
||||
@@ -256,24 +221,7 @@ const ItemSelectModalContent = ({
|
||||
)}
|
||||
</SelectGridLayout>
|
||||
);
|
||||
};
|
||||
|
||||
const ItemSelectModalFrame = (props: Pick<ItemSelectModalContentProps, "actions" | "innerProps">) => {
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: integrationData } = clientApi.integration.all.useQuery();
|
||||
const { data: customWidgetDefs } = clientApi.customWidget.all.useQuery();
|
||||
|
||||
return (
|
||||
<ItemSelectModalContent
|
||||
{...props}
|
||||
integrationData={integrationData}
|
||||
customWidgetDefs={customWidgetDefs}
|
||||
ensureIntegrationDataAsync={() => utils.integration.all.ensureData()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ItemSelectModal = createModal<void>((props) => <ItemSelectModalFrame {...props} />).withOptions({
|
||||
}).withOptions({
|
||||
defaultTitle: (t) => t("item.create.title"),
|
||||
size: modalSizeSelect,
|
||||
});
|
||||
@@ -281,9 +229,6 @@ export const ItemSelectModal = createModal<void>((props) => <ItemSelectModalFram
|
||||
const WidgetItem = ({
|
||||
item,
|
||||
onSelect,
|
||||
onIntent,
|
||||
disabled,
|
||||
loading,
|
||||
hasMatchingIntegration,
|
||||
}: {
|
||||
item: {
|
||||
@@ -294,27 +239,23 @@ const WidgetItem = ({
|
||||
icon: TablerIcon;
|
||||
};
|
||||
onSelect: () => void;
|
||||
onIntent: () => void;
|
||||
disabled: boolean;
|
||||
loading: boolean;
|
||||
hasMatchingIntegration: boolean;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={classes.card}
|
||||
h={selectGridCardHeight}
|
||||
withBorder
|
||||
pos="relative"
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
"--_hover-opacity": "0",
|
||||
borderColor: hasMatchingIntegration ? "var(--mantine-color-blue-6)" : undefined,
|
||||
borderWidth: hasMatchingIntegration ? 2 : undefined,
|
||||
}}
|
||||
onFocus={onIntent}
|
||||
onPointerEnter={onIntent}
|
||||
aria-busy={loading || undefined}
|
||||
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
|
||||
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
@@ -331,17 +272,18 @@ const WidgetItem = ({
|
||||
<SupportedIntegrations integrations={item.supportedIntegrations} />
|
||||
</Stack>
|
||||
<Box
|
||||
className={classes.action}
|
||||
pos="absolute"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
p="xs"
|
||||
style={{
|
||||
opacity: "var(--_hover-opacity)",
|
||||
transition: "opacity 150ms ease",
|
||||
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
|
||||
}}
|
||||
>
|
||||
<Button onClick={onSelect} variant="light" size="xs" fullWidth disabled={disabled} loading={loading}>
|
||||
<Button onClick={onSelect} variant="light" size="xs" fullWidth>
|
||||
{t(`item.create.addToBoard`)}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import {
|
||||
applyCustomWidgetSourceSetup,
|
||||
getCustomWidgetSourceSetups,
|
||||
getImportReview,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import type { CustomWidgetSecretKind, HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import {
|
||||
createCustomWidgetSourceSetupValues,
|
||||
CustomWidgetSourceSetupPanel,
|
||||
ImportReviewDialog,
|
||||
isCustomWidgetSourceSetupReady,
|
||||
} from "@homarr/custom-widgets/workbench";
|
||||
import type { CustomWidgetSourceSetupValue } from "@homarr/custom-widgets/workbench";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface CustomWidgetImportDialogProps {
|
||||
opened: boolean;
|
||||
widget: HomarrCustomWidgetV2 | null;
|
||||
stackId?: string;
|
||||
zIndex?: number;
|
||||
onClose(): void;
|
||||
onImported?(result: { id: string }): void;
|
||||
}
|
||||
|
||||
export function CustomWidgetImportDialog({
|
||||
opened,
|
||||
widget,
|
||||
stackId,
|
||||
zIndex,
|
||||
onClose,
|
||||
onImported,
|
||||
}: CustomWidgetImportDialogProps) {
|
||||
const t = useScopedI18n("customWidget");
|
||||
const router = useRouter();
|
||||
const utils = clientApi.useUtils();
|
||||
const setups = useMemo(() => getCustomWidgetSourceSetups(widget?.sources ?? {}), [widget]);
|
||||
const [values, setValues] = useState<Record<string, CustomWidgetSourceSetupValue>>({});
|
||||
const configuredWidget = useMemo(() => {
|
||||
if (!widget) return null;
|
||||
return {
|
||||
...widget,
|
||||
sources: applyCustomWidgetSourceSetup(
|
||||
widget.sources,
|
||||
Object.fromEntries(
|
||||
Object.entries(values).map(([sourceId, value]) => [
|
||||
sourceId,
|
||||
{ baseUrl: value.baseUrl, networkScope: value.networkScope },
|
||||
]),
|
||||
),
|
||||
),
|
||||
};
|
||||
}, [values, widget]);
|
||||
const review = useMemo(() => getImportReview(configuredWidget), [configuredWidget]);
|
||||
const mutation = clientApi.customWidget.import.useMutation({
|
||||
onSuccess: (result) => {
|
||||
showSuccessNotification({ title: t("action.import"), message: t("notification.imported") });
|
||||
void utils.customWidget.list.invalidate();
|
||||
void utils.customWidget.available.invalidate();
|
||||
void revalidatePathActionAsync("/manage/custom-widgets").then(() => router.refresh());
|
||||
onImported?.(result);
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorNotification({ title: t("action.import"), message: error.message || t("notification.importError") });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) setValues(createCustomWidgetSourceSetupValues(setups));
|
||||
}, [opened, setups]);
|
||||
|
||||
const importWidget = () => {
|
||||
if (!configuredWidget) return;
|
||||
const secrets = Object.entries(values).flatMap(([sourceId, value]) =>
|
||||
Object.entries(value.secrets).flatMap(([kind, secret]) =>
|
||||
secret?.trim() ? [{ sourceId, kind: kind as CustomWidgetSecretKind, value: secret }] : [],
|
||||
),
|
||||
);
|
||||
mutation.mutate({ widget: configuredWidget, secrets });
|
||||
};
|
||||
|
||||
return (
|
||||
<ImportReviewDialog
|
||||
opened={opened}
|
||||
stackId={stackId}
|
||||
zIndex={zIndex}
|
||||
review={review}
|
||||
pending={mutation.isPending}
|
||||
confirmDisabled={!isCustomWidgetSourceSetupReady(setups, values)}
|
||||
onClose={onClose}
|
||||
onConfirm={importWidget}
|
||||
messages={{
|
||||
title: t("importReview.title"),
|
||||
description: t("importReview.description"),
|
||||
name: t("importReview.name"),
|
||||
origin: t("importReview.origin"),
|
||||
authentication: t("importReview.authentication"),
|
||||
networkScope: t("importReview.networkScope"),
|
||||
methods: t("importReview.methods"),
|
||||
permissions: t("importReview.permissions"),
|
||||
actionWarningTitle: t("importReview.actionWarning.title"),
|
||||
actionWarningDescription: t("importReview.actionWarning.description"),
|
||||
cancel: t("importReview.cancel"),
|
||||
confirm: t("importReview.confirm"),
|
||||
permission: (permission) => t(`preview.request.permission.${permission}` as never),
|
||||
}}
|
||||
>
|
||||
<CustomWidgetSourceSetupPanel
|
||||
setups={setups}
|
||||
values={values}
|
||||
onChange={(sourceId, value) => setValues((current) => ({ ...current, [sourceId]: value }))}
|
||||
messages={{
|
||||
title: t("importReview.sourceSetup.title"),
|
||||
description: t("importReview.sourceSetup.description"),
|
||||
suggestedUrl: t("importReview.sourceSetup.suggestedUrl"),
|
||||
baseUrl: t("workbench.sources.baseUrl"),
|
||||
networkScope: t("workbench.sources.networkScope"),
|
||||
authentication: t("workbench.sources.authentication"),
|
||||
confirmUrl: t("importReview.sourceSetup.confirmUrl"),
|
||||
ready: t("importReview.sourceSetup.ready"),
|
||||
needsUrl: t("importReview.sourceSetup.needsUrl"),
|
||||
credentialsMissing: t("importReview.sourceSetup.credentialsMissing"),
|
||||
credentialsOptional: t("importReview.sourceSetup.credentialsOptional"),
|
||||
configured: t("workbench.sources.configured"),
|
||||
secret: (kind) => t(`importReview.credentials.field.${kind}`),
|
||||
urlError: (issue) => t(`workbench.sources.baseUrlError.${issue}`),
|
||||
}}
|
||||
/>
|
||||
</ImportReviewDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Code,
|
||||
Group,
|
||||
Image,
|
||||
Loader,
|
||||
Modal,
|
||||
Pagination,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconBuildingStore,
|
||||
IconDownload,
|
||||
IconFlag,
|
||||
IconSearch,
|
||||
IconThumbDown,
|
||||
IconThumbUp,
|
||||
} from "@tabler/icons-react";
|
||||
import { CUSTOM_WIDGET_SCHEMA } from "@homarr/custom-widgets/core";
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import {
|
||||
useWorkshopCssImportMutation,
|
||||
useWorkshopQuery,
|
||||
useWorkshopReportMutation,
|
||||
useWorkshopSubmissionQuery,
|
||||
useWorkshopVoteMutation,
|
||||
useWorkshopWidgetImportMutation,
|
||||
} from "@homarr/workshop/backend";
|
||||
import type {
|
||||
WorkshopReport,
|
||||
WorkshopSubmissionDetail,
|
||||
WorkshopSubmissionType,
|
||||
WorkshopUser,
|
||||
} from "@homarr/workshop/schema";
|
||||
import {
|
||||
validateWorkshopContent,
|
||||
validateWorkshopWidget,
|
||||
WORKSHOP_CSS_SCHEMA,
|
||||
workshopExportFilename,
|
||||
} from "@homarr/workshop/schema";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { createWorkshopClient } from "./workshop-client";
|
||||
|
||||
function downloadWorkshopSubmission(submission: WorkshopSubmissionDetail) {
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([submission.content], { type: submission.type === "customCss" ? "text/css" : "application/json" }),
|
||||
);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = workshopExportFilename(submission.title, submission.type);
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
interface WorkshopBrowserProps {
|
||||
type?: WorkshopSubmissionType;
|
||||
onInstall?(widget: HomarrCustomWidgetV2): Promise<void>;
|
||||
onUseCss?(css: string): void;
|
||||
}
|
||||
|
||||
export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }: WorkshopBrowserProps) {
|
||||
const t = useScopedI18n("workshop");
|
||||
const client = useMemo(createWorkshopClient, []);
|
||||
const [user, setUser] = useState<WorkshopUser | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [sort, setSort] = useState<"top" | "newest">("top");
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch] = useDebouncedValue(search, 250);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [reportOpened, reportControls] = useDisclosure(false);
|
||||
const [reportCategory, setReportCategory] = useState<WorkshopReport["category"]>("other");
|
||||
const [reportExplanation, setReportExplanation] = useState("");
|
||||
const [loginPending, setLoginPending] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const [cssAwaitingConfirmation, setCssAwaitingConfirmation] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = client.subscribeToAuth(setUser);
|
||||
void client.refreshAuth().then(setUser);
|
||||
return unsubscribe;
|
||||
}, [client]);
|
||||
|
||||
const list = useWorkshopQuery(client, { page, perPage: 12, type, sort, search: debouncedSearch });
|
||||
const detail = useWorkshopSubmissionQuery(client, selectedId ?? "");
|
||||
const detailValidation = useMemo(() => {
|
||||
if (!detail.data) return null;
|
||||
return type === "customCss"
|
||||
? validateWorkshopContent("customCss", detail.data.content)
|
||||
: validateWorkshopWidget(detail.data.content);
|
||||
}, [detail.data, type]);
|
||||
const expectedSchema = type === "customCss" ? WORKSHOP_CSS_SCHEMA : CUSTOM_WIDGET_SCHEMA;
|
||||
const detailCompatible =
|
||||
detail.data?.type === type && detailValidation?.success === true && detail.data.widgetSchema === expectedSchema;
|
||||
const signIn = () => {
|
||||
setLoginPending(true);
|
||||
setLoginError(null);
|
||||
void client
|
||||
.signInWithGitHub()
|
||||
.then(setUser)
|
||||
.catch((cause: unknown) => {
|
||||
const message = cause instanceof Error ? cause.message : t("signInError");
|
||||
setLoginError(message);
|
||||
showErrorNotification({ title: t("signIn"), message });
|
||||
})
|
||||
.finally(() => setLoginPending(false));
|
||||
};
|
||||
const install = useWorkshopWidgetImportMutation(onInstall, () => setSelectedId(null));
|
||||
const useCss = useWorkshopCssImportMutation(onUseCss, () => {
|
||||
setCssAwaitingConfirmation(null);
|
||||
setSelectedId(null);
|
||||
});
|
||||
const vote = useWorkshopVoteMutation(client);
|
||||
const report = useWorkshopReportMutation(client);
|
||||
const submitReport = () =>
|
||||
report.mutate(
|
||||
{ submission: selectedId ?? "", category: reportCategory, explanation: reportExplanation },
|
||||
{
|
||||
onSuccess: () => {
|
||||
reportControls.close();
|
||||
setReportExplanation("");
|
||||
showSuccessNotification({ title: t("reportSent"), message: t("reportSentDescription") });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Card withBorder radius="lg" p="lg">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Group align="flex-start">
|
||||
<Box
|
||||
p="sm"
|
||||
bg="var(--mantine-color-default-hover)"
|
||||
style={{ borderRadius: "var(--mantine-radius-md)", lineHeight: 0 }}
|
||||
>
|
||||
<IconBuildingStore size={24} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Title order={2}>{t("title")}</Title>
|
||||
<Text c="dimmed" size="sm" maw={640}>
|
||||
{type === "customCss" ? t("descriptionCss") : t("description")}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
{user ? (
|
||||
<Group gap="xs">
|
||||
<Badge variant="light">{user.displayName}</Badge>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
client.signOut();
|
||||
setUser(null);
|
||||
}}
|
||||
>
|
||||
{t("signOut")}
|
||||
</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<Button size="xs" loading={loginPending} onClick={signIn}>
|
||||
{t("signIn")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Card>
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={18} />}>
|
||||
{type === "customCss" ? t("securityNoticeCss") : t("securityNotice")}
|
||||
</Alert>
|
||||
{loginError && <Alert color="red">{loginError}</Alert>}
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }}>
|
||||
<TextInput
|
||||
label={type === "customCss" ? t("searchCss") : t("search")}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
rightSection={list.isFetching ? <Loader size={16} /> : undefined}
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.currentTarget.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
label={t("sort")}
|
||||
value={sort}
|
||||
onChange={(value) => {
|
||||
setSort(value === "newest" ? "newest" : "top");
|
||||
setPage(1);
|
||||
}}
|
||||
data={[
|
||||
{ value: "top", label: t("sortTop") },
|
||||
{ value: "newest", label: t("sortNewest") },
|
||||
]}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
{list.isLoading ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
|
||||
<Skeleton h={280} />
|
||||
<Skeleton h={280} />
|
||||
<Skeleton h={280} />
|
||||
</SimpleGrid>
|
||||
) : list.isError ? (
|
||||
<Alert color="red">{list.error instanceof Error ? list.error.message : t("unavailable")}</Alert>
|
||||
) : list.data?.items.length ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
|
||||
{list.data.items.map((item) => (
|
||||
<Card key={item.id} withBorder radius="md" p="md">
|
||||
{item.screenshots[0] ? (
|
||||
<Card.Section>
|
||||
<Image src={client.fileUrl(item.id, item.screenshots[0], "480x320")} h={170} alt="" />
|
||||
</Card.Section>
|
||||
) : (
|
||||
<Card.Section>
|
||||
<Box
|
||||
h={170}
|
||||
bg="var(--mantine-color-default-hover)"
|
||||
style={{ display: "grid", placeItems: "center" }}
|
||||
>
|
||||
<Code>{item.type === "customCss" ? "CSS" : "JSX"}</Code>
|
||||
</Box>
|
||||
</Card.Section>
|
||||
)}
|
||||
<Stack gap="xs" mt="md">
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("author", { name: item.authorName })}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
<IconThumbUp size={13} /> {item.upvotes} · <IconThumbDown size={13} /> {item.downvotes}
|
||||
</Text>
|
||||
</Group>
|
||||
<Badge color={item.widgetSchema === expectedSchema ? "green" : "red"} variant="light">
|
||||
{item.widgetSchema}
|
||||
</Badge>
|
||||
<Group gap="xs">
|
||||
{item.outdated && <Badge color="yellow">Outdated</Badge>}
|
||||
{item.reportCount > 0 && (
|
||||
<Badge color="red">
|
||||
{item.reportCount} report{item.reportCount === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text fw={700} lineClamp={1}>
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" lineClamp={2}>
|
||||
{item.description || t("noDescription")}
|
||||
</Text>
|
||||
<Button variant="light" onClick={() => setSelectedId(item.id)}>
|
||||
{type === "customCss" ? t("inspectCss") : t("inspect")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
{type === "customCss" ? t("emptyCss") : t("empty")}
|
||||
</Text>
|
||||
)}
|
||||
{(list.data?.totalPages ?? 0) > 1 && (
|
||||
<Pagination total={list.data?.totalPages ?? 1} value={page} onChange={setPage} mx="auto" />
|
||||
)}
|
||||
|
||||
<Modal
|
||||
opened={selectedId !== null}
|
||||
onClose={() => setSelectedId(null)}
|
||||
title={detail.data?.title ?? t("details")}
|
||||
size="xl"
|
||||
>
|
||||
{detail.isPending ? (
|
||||
<Skeleton h={400} />
|
||||
) : detail.isError || !detail.data ? (
|
||||
<Alert color="red">{detail.error instanceof Error ? detail.error.message : t("loadError")}</Alert>
|
||||
) : (
|
||||
<Stack>
|
||||
<Text>{detail.data.description}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("author", { name: detail.data.authorName })}
|
||||
</Text>
|
||||
{detail.data.outdated && (
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={18} />}>
|
||||
The author marked this submission as outdated. Review it carefully before importing.
|
||||
</Alert>
|
||||
)}
|
||||
{detail.data.reportCount > 0 && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
|
||||
This submission has {detail.data.reportCount} open community report
|
||||
{detail.data.reportCount === 1 ? "" : "s"}. It remains installable, but you should review its content
|
||||
first.
|
||||
</Alert>
|
||||
)}
|
||||
{detail.data.screenshots.length > 0 && (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }}>
|
||||
{detail.data.screenshots.map((file, index) => (
|
||||
<Image
|
||||
key={file}
|
||||
src={client.fileUrl(detail.data.id, file, "960x640")}
|
||||
radius="md"
|
||||
alt={`${detail.data.title} screenshot ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
{type === "customWidget" && detailValidation?.success && typeof detailValidation.data !== "string" && (
|
||||
<CapabilitySummary widget={detailValidation.data} />
|
||||
)}
|
||||
{detailValidation && !detailValidation.success && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
|
||||
{t("installErrorDescription")} {detailValidation.error}
|
||||
</Alert>
|
||||
)}
|
||||
{detailValidation?.success && !detailCompatible && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
|
||||
{t("installErrorDescription")}
|
||||
</Alert>
|
||||
)}
|
||||
<Code block mah={360} style={{ overflow: "auto", whiteSpace: "pre" }}>
|
||||
{detail.data.content}
|
||||
</Code>
|
||||
<Group justify="space-between">
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<IconDownload size={16} />}
|
||||
onClick={() => downloadWorkshopSubmission(detail.data)}
|
||||
>
|
||||
{t("export")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={<IconThumbUp size={16} />}
|
||||
loading={vote.isPending && vote.variables?.value === 1}
|
||||
disabled={!user || vote.isPending}
|
||||
onClick={() => vote.mutate({ submission: detail.data.id, value: 1 })}
|
||||
>
|
||||
{detail.data.upvotes}
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={<IconThumbDown size={16} />}
|
||||
loading={vote.isPending && vote.variables?.value === -1}
|
||||
disabled={!user || vote.isPending}
|
||||
onClick={() => vote.mutate({ submission: detail.data.id, value: -1 })}
|
||||
>
|
||||
{detail.data.downvotes}
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<IconFlag size={16} />}
|
||||
disabled={!user}
|
||||
onClick={reportControls.open}
|
||||
>
|
||||
{t("report")}
|
||||
</Button>
|
||||
</Group>
|
||||
{onInstall && (
|
||||
<Button
|
||||
loading={install.isPending}
|
||||
disabled={!detailCompatible}
|
||||
onClick={() =>
|
||||
detailValidation?.success &&
|
||||
typeof detailValidation.data !== "string" &&
|
||||
install.mutate(detailValidation.data)
|
||||
}
|
||||
>
|
||||
{t("install")}
|
||||
</Button>
|
||||
)}
|
||||
{onUseCss && (
|
||||
<Button
|
||||
disabled={!detailCompatible}
|
||||
onClick={() =>
|
||||
detailValidation?.success &&
|
||||
typeof detailValidation.data === "string" &&
|
||||
setCssAwaitingConfirmation(detailValidation.data)
|
||||
}
|
||||
>
|
||||
{t("useCss")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
{!user && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("signInHint")}
|
||||
</Text>
|
||||
)}
|
||||
{vote.error && <Alert color="red">{vote.error.message || t("voteError")}</Alert>}
|
||||
{install.error && <Alert color="red">{install.error.message}</Alert>}
|
||||
{useCss.error && <Alert color="red">{useCss.error.message}</Alert>}
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal opened={reportOpened} onClose={reportControls.close} title={t("reportTitle")}>
|
||||
<Stack>
|
||||
<Select
|
||||
label={t("reportReason")}
|
||||
value={reportCategory}
|
||||
onChange={(value) => setReportCategory((value as WorkshopReport["category"]) ?? "other")}
|
||||
data={["malicious", "spam", "copyright", "inappropriate", "other"]}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<Textarea
|
||||
label={t("reportExplanation")}
|
||||
minRows={4}
|
||||
value={reportExplanation}
|
||||
onChange={(event) => setReportExplanation(event.currentTarget.value)}
|
||||
/>
|
||||
{report.error && <Alert color="red">{report.error.message}</Alert>}
|
||||
<Button loading={report.isPending} disabled={reportExplanation.trim().length < 3} onClick={submitReport}>
|
||||
{t("reportSend")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
<Modal
|
||||
opened={cssAwaitingConfirmation !== null}
|
||||
onClose={() => setCssAwaitingConfirmation(null)}
|
||||
title="Import this Custom CSS?"
|
||||
>
|
||||
<Stack>
|
||||
<Alert color="yellow" icon={<IconAlertTriangle size={18} />}>
|
||||
Review community CSS before applying it. Importing replaces the current Custom CSS editor value, but nothing
|
||||
is saved until you save the board settings.
|
||||
</Alert>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setCssAwaitingConfirmation(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
loading={useCss.isPending}
|
||||
onClick={() => {
|
||||
if (cssAwaitingConfirmation !== null) useCss.mutate(cssAwaitingConfirmation);
|
||||
}}
|
||||
>
|
||||
Import CSS
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function CapabilitySummary({ widget }: { widget: HomarrCustomWidgetV2 }) {
|
||||
const t = useScopedI18n("workshop");
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }}>
|
||||
<Card withBorder p="sm">
|
||||
<Text fw={600} size="sm" mb="xs">
|
||||
{t("sources")}
|
||||
</Text>
|
||||
<Stack gap={6}>
|
||||
{Object.entries(widget.sources).map(([sourceId, source]) => (
|
||||
<Box key={sourceId}>
|
||||
<Text size="sm" fw={500}>
|
||||
{source.name}
|
||||
</Text>
|
||||
<Group gap={4}>
|
||||
<Badge size="xs" variant="light">
|
||||
{new URL(source.baseUrl).origin}
|
||||
</Badge>
|
||||
<Badge size="xs" variant="outline">
|
||||
{source.networkScope}
|
||||
</Badge>
|
||||
<Badge size="xs" variant="outline">
|
||||
{typeof source.auth === "string" ? source.auth : source.auth.type}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
<Card withBorder p="sm">
|
||||
<Text fw={600} size="sm" mb="xs">
|
||||
{t("requests")}
|
||||
</Text>
|
||||
{Object.keys(widget.requests).length === 0 ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("noRequests")}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={6}>
|
||||
{Object.entries(widget.requests).map(([requestId, request]) => (
|
||||
<Group key={requestId} justify="space-between" wrap="nowrap">
|
||||
<Text size="xs" ff="monospace" truncate>
|
||||
{requestId}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Badge size="xs">{request.kind}</Badge>
|
||||
<Badge size="xs" variant="light">
|
||||
{request.method}
|
||||
</Badge>
|
||||
<Badge size="xs" variant="outline">
|
||||
{request.permission}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { WorkshopBackend } from "@homarr/workshop/backend";
|
||||
import { WORKSHOP_API_URL } from "@homarr/workshop/schema";
|
||||
|
||||
let browserClient: WorkshopBackend | undefined;
|
||||
|
||||
export function createWorkshopClient() {
|
||||
if (typeof document === "undefined") return new WorkshopBackend(WORKSHOP_API_URL);
|
||||
if (browserClient) return browserClient;
|
||||
|
||||
const runtimeUrl = document.querySelector<HTMLMetaElement>('meta[name="homarr-workshop-api-url"]')?.content;
|
||||
|
||||
browserClient = new WorkshopBackend(runtimeUrl || WORKSHOP_API_URL);
|
||||
return browserClient;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button, Modal } from "@mantine/core";
|
||||
import { IconDownload } from "@tabler/icons-react";
|
||||
|
||||
import { WorkshopBrowser } from "./workshop-browser";
|
||||
|
||||
export function WorkshopCssImportButton({ onImport }: { onImport(css: string): void }) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button type="button" variant="light" leftSection={<IconDownload size={16} />} onClick={() => setOpened(true)}>
|
||||
Import from Workshop
|
||||
</Button>
|
||||
<Modal opened={opened} onClose={() => setOpened(false)} title="Import Custom CSS" size="xl">
|
||||
<WorkshopBrowser
|
||||
type="customCss"
|
||||
onUseCss={(css) => {
|
||||
onImport(css);
|
||||
setOpened(false);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { Button, getDefaultZIndex, Modal, useModalsStack } from "@mantine/core";
|
||||
import { IconBuildingStore } from "@tabler/icons-react";
|
||||
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
|
||||
import { WorkshopBrowser } from "./workshop-browser";
|
||||
|
||||
interface WorkshopInstallButtonProps {
|
||||
children?: ReactNode;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export function WorkshopInstallButton({ children, fullWidth }: WorkshopInstallButtonProps = {}) {
|
||||
const t = useScopedI18n("workshop");
|
||||
const stack = useModalsStack(["workshop", "review"]);
|
||||
const [pendingWidget, setPendingWidget] = useState<HomarrCustomWidgetV2 | null>(null);
|
||||
const zIndex = getDefaultZIndex("modal") + 10;
|
||||
|
||||
const closeReview = () => {
|
||||
stack.close("review");
|
||||
setPendingWidget(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<IconBuildingStore size={16} />}
|
||||
onClick={() => stack.open("workshop")}
|
||||
fullWidth={fullWidth}
|
||||
>
|
||||
{children ?? t("title")}
|
||||
</Button>
|
||||
<Modal.Stack>
|
||||
<Modal {...stack.register("workshop")} title={t("installDialog")} size="90%" zIndex={zIndex}>
|
||||
<WorkshopBrowser
|
||||
onInstall={async (widget) => {
|
||||
setPendingWidget(widget);
|
||||
stack.open("review");
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
<CustomWidgetImportDialog
|
||||
{...stack.register("review")}
|
||||
widget={pendingWidget}
|
||||
zIndex={zIndex + 1}
|
||||
onClose={closeReview}
|
||||
onImported={() => {
|
||||
stack.closeAll();
|
||||
setPendingWidget(null);
|
||||
}}
|
||||
/>
|
||||
</Modal.Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
|
||||
import { WorkshopBrowser } from "./workshop-browser";
|
||||
|
||||
export function WorkshopInstaller() {
|
||||
const router = useRouter();
|
||||
const [pendingWidget, setPendingWidget] = useState<HomarrCustomWidgetV2 | null>(null);
|
||||
const [reviewOpened, reviewControls] = useDisclosure(false);
|
||||
|
||||
const install = async (widget: HomarrCustomWidgetV2) => {
|
||||
setPendingWidget(widget);
|
||||
reviewControls.open();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<WorkshopBrowser onInstall={install} />
|
||||
<CustomWidgetImportDialog
|
||||
opened={reviewOpened}
|
||||
widget={pendingWidget}
|
||||
onClose={() => {
|
||||
reviewControls.close();
|
||||
setPendingWidget(null);
|
||||
}}
|
||||
onImported={(result) => router.push(`/manage/custom-widgets/edit/${result.id}`)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Alert, Button, Checkbox, FileInput, Group, Modal, Stack, Textarea, TextInput } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useWorkshopCreateMutation } from "@homarr/workshop/backend";
|
||||
import type { WorkshopUser } from "@homarr/workshop/schema";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { createWorkshopClient } from "./workshop-client";
|
||||
|
||||
export function WorkshopPublishModal({
|
||||
opened,
|
||||
onClose,
|
||||
widget,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose(): void;
|
||||
widget: { id: string; name: string };
|
||||
}) {
|
||||
const t = useScopedI18n("workshop");
|
||||
const client = useMemo(createWorkshopClient, []);
|
||||
const utils = clientApi.useUtils();
|
||||
const [user, setUser] = useState<WorkshopUser | null>(null);
|
||||
const [title, setTitle] = useState(widget.name);
|
||||
const [description, setDescription] = useState("");
|
||||
const [screenshots, setScreenshots] = useState<File[]>([]);
|
||||
const createSubmission = useWorkshopCreateMutation(client);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [published, setPublished] = useState(false);
|
||||
const [sourceUrlsReviewed, setSourceUrlsReviewed] = useState(false);
|
||||
const definition = clientApi.customWidget.get.useQuery({ id: widget.id }, { enabled: opened });
|
||||
const privateSources = Object.entries(definition.data?.sources ?? {}).filter(
|
||||
([, source]) => source.networkScope !== "public",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = client.subscribeToAuth(setUser);
|
||||
void client.refreshAuth().then(setUser);
|
||||
return unsubscribe;
|
||||
}, [client]);
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
setTitle(widget.name);
|
||||
setDescription("");
|
||||
setScreenshots([]);
|
||||
setError(null);
|
||||
setPublished(false);
|
||||
setSourceUrlsReviewed(false);
|
||||
}, [opened, widget.name]);
|
||||
|
||||
const publish = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const definition = await utils.customWidget.export.fetch({ id: widget.id });
|
||||
await createSubmission.mutateAsync({
|
||||
input: { type: "customWidget", title, description, content: JSON.stringify(definition) },
|
||||
screenshots,
|
||||
});
|
||||
setPublished(true);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : t("publish.error"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={t("publish.title")} size="lg">
|
||||
<Stack>
|
||||
{published ? (
|
||||
<Alert color="green">{t("publish.success")}</Alert>
|
||||
) : (
|
||||
<>
|
||||
{!user && (
|
||||
<Alert color="blue">
|
||||
<Group justify="space-between">
|
||||
<span>{t("publish.signInHint")}</span>
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() =>
|
||||
void client
|
||||
.signInWithGitHub()
|
||||
.then(setUser)
|
||||
.catch((cause: Error) => {
|
||||
setError(cause.message);
|
||||
showErrorNotification({ title: t("signIn"), message: cause.message });
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("signIn")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Alert>
|
||||
)}
|
||||
<TextInput
|
||||
label={t("publish.titleField")}
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Textarea
|
||||
label={t("publish.descriptionField")}
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.currentTarget.value)}
|
||||
minRows={3}
|
||||
/>
|
||||
<FileInput
|
||||
label={t("publish.screenshots")}
|
||||
description={t("publish.screenshotsDescription")}
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
multiple
|
||||
value={screenshots}
|
||||
onChange={setScreenshots}
|
||||
/>
|
||||
{privateSources.length > 0 && (
|
||||
<Alert color="yellow">
|
||||
{t("publish.privateSourceWarning", {
|
||||
sources: privateSources.map(([sourceId, source]) => source.name ?? sourceId).join(", "),
|
||||
})}
|
||||
<Checkbox
|
||||
mt="sm"
|
||||
checked={sourceUrlsReviewed}
|
||||
onChange={(event) => setSourceUrlsReviewed(event.currentTarget.checked)}
|
||||
label={t("publish.privateSourceConfirmation")}
|
||||
/>
|
||||
</Alert>
|
||||
)}
|
||||
{error && <Alert color="red">{error}</Alert>}
|
||||
<Button
|
||||
loading={createSubmission.isPending}
|
||||
disabled={!user || title.trim().length < 3 || (privateSources.length > 0 && !sourceUrlsReviewed)}
|
||||
onClick={() => void publish()}
|
||||
>
|
||||
{t("publish.action")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user