Merge remote-tracking branch 'origin/release/v2' into feat/pr6582-nice-changes-release

# Conflicts:
#	apps/nextjs/src/components/board/items/item-select-modal.tsx
This commit is contained in:
Thomas Camlong committed 2026-08-17 12:29:50 +02:00
commit 510845d3b9
55 files changed
+2566 -1731

No files matched your search

+7 -2
View File
@@ -8,7 +8,7 @@ on:
types: [closed]
branches: ["**"]
push:
branches: [main, dev, beta, next, v2]
branches: [main, dev, beta, next, v2, release/v2]
merge_group:
workflow_dispatch:
inputs:
@@ -108,6 +108,7 @@ jobs:
needs: fast-gate
if: |
github.event_name == 'merge_group' ||
(github.event_name == 'push' && github.ref == 'refs/heads/release/v2') ||
(github.event_name == 'pull_request' && github.event.pull_request.draft == false) ||
(github.event_name == 'workflow_dispatch' && (inputs.operation == 'validate' || inputs.operation == 'publish'))
runs-on: ubuntu-latest
@@ -204,6 +205,7 @@ jobs:
!env.ACT &&
(
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
(github.event_name == 'push' && github.ref == 'refs/heads/release/v2') ||
(github.event_name == 'workflow_dispatch' && inputs.operation == 'publish')
)
uses: docker/login-action@v4
@@ -218,6 +220,7 @@ jobs:
!env.ACT &&
(
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
(github.event_name == 'push' && github.ref == 'refs/heads/release/v2') ||
(github.event_name == 'workflow_dispatch' && inputs.operation == 'publish')
)
uses: docker/build-push-action@v7
@@ -243,6 +246,7 @@ jobs:
if: |
(
(github.event_name == 'pull_request' && github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository) ||
(github.event_name == 'push' && github.ref == 'refs/heads/release/v2') ||
(github.event_name == 'workflow_dispatch' && inputs.operation == 'publish')
)
runs-on: ubuntu-24.04-arm
@@ -294,6 +298,7 @@ jobs:
if: |
(
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
(github.event_name == 'push' && github.ref == 'refs/heads/release/v2') ||
(github.event_name == 'workflow_dispatch' && inputs.operation == 'publish')
)
runs-on: ubuntu-latest
@@ -303,7 +308,7 @@ jobs:
packages: write
pull-requests: read
env:
PREVIEW_TAG: pr-${{ github.event.pull_request.number || inputs.pr_number }}
PREVIEW_TAG: ${{ github.event_name == 'push' && 'v2' || format('pr-{0}', github.event.pull_request.number || inputs.pr_number) }}
steps:
- name: Log in to GHCR
if: "!env.ACT"
@@ -4,7 +4,7 @@ import type { PropsWithChildren } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Anchor, Button, Card, Code, Collapse, Divider, PasswordInput, Stack, Text, TextInput } from "@mantine/core";
import { useDisclosure, useMounted } from "@mantine/hooks";
import { useDisclosure } from "@mantine/hooks";
import { z } from "zod/v4";
import { signIn } from "@homarr/auth/client";
@@ -32,7 +32,6 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
const searchParams = useSearchParams();
const isError = searchParams.has("error");
const router = useRouter();
const isMounted = useMounted();
const [isPending, setIsPending] = useState(false);
const form = useZodForm(extendedValidation, {
initialValues: {
@@ -140,7 +139,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
{providers.includes("credentials") && (
<Stack gap="sm">
<SubmitButton isMounted={isMounted} isPending={isPending} form={form} provider="credentials">
<SubmitButton isPending={isPending} form={form} provider="credentials">
{t("action.login.label")}
</SubmitButton>
<PasswordForgottenCollapse username={form.values.name} />
@@ -148,7 +147,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
)}
{providers.includes("ldap") && (
<SubmitButton isMounted={isMounted} isPending={isPending} form={form} provider="ldap">
<SubmitButton isPending={isPending} form={form} provider="ldap">
{t("action.login.labelWith", { provider: "LDAP" })}
</SubmitButton>
)}
@@ -159,13 +158,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
)}
{providers.includes("oidc") && (
<Button
type="button"
fullWidth
variant="light"
disabled={!isMounted || isPending}
onClick={() => void signInAsync("oidc")}
>
<Button type="button" fullWidth variant="light" disabled={isPending} onClick={() => void signInAsync("oidc")}>
{t("action.login.labelWith", { provider: oidcClientName })}
</Button>
)}
@@ -175,13 +168,12 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
};
interface SubmitButtonProps {
isMounted: boolean;
isPending: boolean;
form: UseFormReturnType<FormType>;
provider: "credentials" | "ldap";
}
const SubmitButton = ({ isMounted, isPending, form, provider, children }: PropsWithChildren<SubmitButtonProps>) => {
const SubmitButton = ({ isPending, form, provider, children }: PropsWithChildren<SubmitButtonProps>) => {
const isCurrentProviderActive = form.getValues().provider === provider;
return (
@@ -192,7 +184,7 @@ const SubmitButton = ({ isMounted, isPending, form, provider, children }: PropsW
fullWidth
onClick={() => form.setFieldValue("provider", provider)}
loading={isPending && isCurrentProviderActive}
disabled={!isMounted || (isPending && !isCurrentProviderActive)}
disabled={isPending && !isCurrentProviderActive}
>
{children}
</Button>
@@ -9,6 +9,8 @@ import {
IconCopy,
IconDots,
IconDownload,
IconPlayerPause,
IconPlayerPlay,
IconSparkles,
IconTrash,
IconUpload,
@@ -21,9 +23,10 @@ import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
import { WorkshopPublishModal } from "~/components/workshop/workshop-publish-modal";
import { downloadJson } from "~/components/custom-widgets/download";
const iconProps = { size: 16, stroke: 1.5 };
@@ -40,12 +43,11 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
const { openConfirmModal } = useConfirmModal();
const deleteMutation = clientApi.customWidget.delete.useMutation();
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
const toggleMutation = clientApi.customWidget.toggleEnabled.useMutation();
const utils = clientApi.useUtils();
const [publishOpened, publishControls] = useDisclosure(false);
const [migrationOpened, migrationControls] = useDisclosure(false);
const migrationFileInputRef = useRef<HTMLInputElement>(null);
const [migratedWidget, setMigratedWidget] = useState<HomarrCustomWidgetV2 | null>(null);
const copyMigrationPrompt = async () => {
try {
const result = await utils.customWidget.legacyMigrationPrompt.fetch({ id: widget.id });
@@ -62,30 +64,41 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
}
};
const copyLegacyDefinition = async () => {
try {
const data = await utils.customWidget.exportLegacy.fetch({ id: widget.id });
await navigator.clipboard.writeText(JSON.stringify(data, null, 2));
showSuccessNotification({
title: t("action.copyLegacyDefinition"),
message: t("notification.legacyDefinitionCopied"),
});
} catch (error) {
showErrorNotification({
title: t("action.copyLegacyDefinition"),
message: error instanceof Error ? error.message : t("notification.legacyDefinitionCopyError"),
});
}
};
const queueMigratedWidget = (text: string) => {
const result = parseCustomWidgetClipboardDetailed(text);
if (!result.success) {
showErrorNotification({
title: t("action.migrate"),
message: formatCustomWidgetImportIssues(result.issues),
});
showErrorNotification({ title: t("action.migrate"), message: formatCustomWidgetImportIssues(result.issues) });
return;
}
setMigratedWidget(result.widget);
migrationControls.open();
};
const notifyMigrationError = () =>
showErrorNotification({ title: t("action.migrate"), message: t("notification.migrationError") });
const pasteMigratedWidget = async () => {
try {
queueMigratedWidget(await navigator.clipboard.readText());
} catch {
showErrorNotification({
title: t("action.migrate"),
message: t("notification.migrationError"),
});
notifyMigrationError();
}
};
const handleMigrationFile = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
@@ -94,15 +107,10 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
try {
queueMigratedWidget(loadEvent.target?.result as string);
} catch {
showErrorNotification({
title: t("action.migrate"),
message: t("notification.migrationError"),
});
notifyMigrationError();
}
});
reader.addEventListener("error", () => {
showErrorNotification({ title: t("action.migrate"), message: t("notification.migrationError") });
});
reader.addEventListener("error", notifyMigrationError);
reader.readAsText(file);
event.target.value = "";
};
@@ -110,18 +118,41 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
const handleExport = async () => {
try {
const data = await utils.customWidget.export.fetch({ id: widget.id });
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${widget.name}.json`;
a.click();
URL.revokeObjectURL(url);
downloadJson(data, `${widget.name}.json`);
} catch {
showErrorNotification({ title: t("action.export"), message: t("notification.exportError") });
}
};
const exportLegacyDefinition = async () => {
try {
const data = await utils.customWidget.exportLegacy.fetch({ id: widget.id });
downloadJson(data, `${widget.name}.legacy.json`);
} catch {
showErrorNotification({ title: t("action.exportLegacyDefinition"), message: t("notification.exportError") });
}
};
const handleToggleEnabled = () => {
toggleMutation.mutate(
{ id: widget.id, enabled: !widget.enabled },
{
onSuccess: async () => {
await utils.widget.customApi.getData.cancel();
void utils.customWidget.list.invalidate();
void utils.widget.customApi.getData.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
},
onError: () => {
showErrorNotification({
title: widget.enabled ? t("action.disable") : t("action.enable"),
message: t("notification.toggleError"),
});
},
},
);
};
const handleDuplicate = () => {
duplicateMutation.mutate(
{ id: widget.id },
@@ -146,22 +177,24 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
title: t("action.delete"),
children: t("action.deleteConfirm", { name: widget.name }),
onConfirm: () => {
const callbacks = {
onSuccess: () => {
showSuccessNotification({
title: t("action.delete"),
message: t("notification.deleted", { name: widget.name }),
});
void utils.customWidget.list.invalidate();
void utils.customWidget.available.invalidate();
void utils.widget.customApi.getData.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
deleteMutation.mutate(
{ id: widget.id },
{
onSuccess: () => {
showSuccessNotification({
title: t("action.delete"),
message: t("notification.deleted", { name: widget.name }),
});
void utils.customWidget.list.invalidate();
void utils.customWidget.available.invalidate();
void utils.widget.customApi.getData.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
},
onError: () => {
showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") });
},
},
onError: () => {
showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") });
},
};
deleteMutation.mutate({ id: widget.id }, callbacks);
);
},
});
};
@@ -177,6 +210,14 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
<Menu.Dropdown>
{widget.valid && (
<>
<Menu.Item
onClick={handleToggleEnabled}
leftSection={widget.enabled ? <IconPlayerPause {...iconProps} /> : <IconPlayerPlay {...iconProps} />}
disabled={toggleMutation.isPending}
>
{widget.enabled ? t("action.disable") : t("action.enable")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
onClick={handleDuplicate}
leftSection={<IconCopy {...iconProps} />}
@@ -187,7 +228,11 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
<Menu.Item onClick={() => void handleExport()} leftSection={<IconDownload {...iconProps} />}>
{t("action.export")}
</Menu.Item>
<Menu.Item onClick={publishControls.open} leftSection={<IconBuildingStore {...iconProps} />}>
<Menu.Item
component={Link}
href={`/manage/custom-widgets/publish/${widget.id}`}
leftSection={<IconBuildingStore {...iconProps} />}
>
{t("action.publishWorkshop")}
</Menu.Item>
<Menu.Divider />
@@ -195,6 +240,13 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
)}
{widget.migrationRequired && (
<>
<Menu.Item onClick={() => void copyLegacyDefinition()} leftSection={<IconCopy {...iconProps} />}>
{t("action.copyLegacyDefinition")}
</Menu.Item>
<Menu.Item onClick={() => void exportLegacyDefinition()} leftSection={<IconDownload {...iconProps} />}>
{t("action.exportLegacyDefinition")}
</Menu.Item>
<Menu.Divider />
<Menu.Item onClick={() => void copyMigrationPrompt()} leftSection={<IconSparkles {...iconProps} />}>
{t("action.copyMigrationPrompt")}
</Menu.Item>
@@ -222,7 +274,6 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
)}
</Menu.Dropdown>
</Menu>
{widget.valid && <WorkshopPublishModal opened={publishOpened} onClose={publishControls.close} widget={widget} />}
{widget.migrationRequired && (
<>
<input
@@ -19,12 +19,18 @@ describe("legacy custom widget migration UI", () => {
});
it("replaces the legacy record through migration and refreshes every consumer", () => {
// The import pipeline is shared by the dialog and the Workshop install page.
const importer = readSource("apps/nextjs/src/components/custom-widgets/use-custom-widget-import.ts");
const dialog = readSource("apps/nextjs/src/components/custom-widgets/custom-widget-import-dialog.tsx");
expect(dialog).toContain("customWidget.migrateLegacy.useMutation");
expect(dialog).toContain("migrateMutation.mutate({ id: legacyId, widget: configuredWidget, secrets })");
expect(dialog).toContain("utils.customWidget.list.invalidate()");
expect(dialog).toContain("utils.customWidget.available.invalidate()");
expect(dialog).toContain("utils.widget.customApi.getData.invalidate()");
expect(importer).toContain("customWidget.migrateLegacy.useMutation");
expect(importer).toMatch(
/migrateMutation\.mutate\(\{\s*id: legacyId,\s*widget: configuredWidget,\s*secrets,?\s*\}\)/u,
);
expect(importer).toContain("utils.customWidget.list.invalidate()");
expect(importer).toContain("utils.customWidget.available.invalidate()");
expect(importer).toContain("utils.widget.customApi.getData.invalidate()");
expect(dialog).toContain("useCustomWidgetImport({");
expect(dialog).toContain("legacyId,");
});
});
@@ -1,15 +1,13 @@
"use client";
import { ActionIcon, Avatar, Badge, Card, Group, Stack, Switch, Text, Tooltip } from "@mantine/core";
import { Avatar, Badge, Button, Group, Text, Tooltip } from "@mantine/core";
import { IconAlertTriangle, IconApi, IconPencil, IconSparkles } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { ManageCollectionItem, ManageCollectionList } from "~/components/manage/manage-collection";
import { NoResults } from "~/components/no-results";
import { CustomWidgetRowActions } from "./_custom-widget-actions";
@@ -31,161 +29,69 @@ export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
}
return (
<Stack gap="sm">
{definitions.map((def) => (
<CustomWidgetCard key={def.id} widget={def} />
<ManageCollectionList ariaLabel={t("page.list.title")}>
{definitions.map((definition) => (
<CustomWidgetRow key={definition.id} widget={definition} />
))}
</Stack>
</ManageCollectionList>
);
};
type WidgetDef = RouterOutputs["customWidget"]["list"][number];
function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
function CustomWidgetRow({ widget }: { widget: WidgetDef }) {
const t = useScopedI18n("customWidget");
const toggleMutation = clientApi.customWidget.toggleEnabled.useMutation();
const utils = clientApi.useUtils();
const handleToggle = () => {
toggleMutation.mutate(
{ id: widget.id, enabled: !widget.enabled },
{
onSuccess: async () => {
await utils.widget.customApi.getData.cancel();
void utils.customWidget.list.invalidate();
void utils.widget.customApi.getData.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
},
onError: () => {
showErrorNotification({
title: widget.enabled ? t("action.disable") : t("action.enable"),
message: t("notification.toggleError"),
});
},
},
);
};
const origins = [...new Set(widget.sources.map((source) => source.origin))];
return (
<Card
padding="sm"
withBorder={!widget.valid}
bd={
widget.migrationRequired
? "1px solid var(--mantine-color-yellow-6)"
: !widget.valid
? "1px solid var(--mantine-color-red-6)"
: undefined
<ManageCollectionItem
leading={
<Avatar
size={40}
radius="sm"
src={widget.iconUrl}
color={widget.valid ? "pink" : "red"}
styles={{ image: { objectFit: "contain" } }}
alt=""
>
<IconApi size={20} stroke={1.5} />
</Avatar>
}
style={{
opacity: widget.valid && !widget.enabled ? 0.55 : 1,
transition: "opacity 150ms ease",
}}
>
<Group justify="space-between" wrap="nowrap" gap="md">
<Group wrap="nowrap" gap="sm" style={{ flex: 1, minWidth: 0 }}>
{widget.iconUrl ? (
<Avatar size={36} radius="sm" src={widget.iconUrl} styles={{ image: { objectFit: "contain" } }} />
) : (
<Avatar size={36} radius="sm" color="red">
<IconApi size={18} />
</Avatar>
)}
<Stack gap={2} style={{ minWidth: 0, flex: 1 }}>
<Text size="sm" fw={600} lineClamp={1} style={{ minWidth: 0 }}>
{widget.name}
title={
<Text component="span" fw={600} lineClamp={1} c={widget.enabled || !widget.valid ? undefined : "dimmed"}>
{widget.name}
</Text>
}
badges={<CustomWidgetStatusBadges widget={widget} />}
description={
<Text size="sm" c="dimmed" lineClamp={2}>
{widget.description || t("page.list.noDescription")}
</Text>
}
metadata={
<Group gap={6} wrap="wrap">
<Text size="xs" c="dimmed">
{t("page.list.requestCount", { count: widget.requestCount })}
</Text>
{origins.map((origin) => (
<Text key={origin} size="xs" c="dimmed" ff="monospace" style={{ wordBreak: "break-all" }}>
· {origin}
</Text>
{widget.migrationRequired ? (
<Text size="xs" c="yellow.8">
{t("page.list.migrationDescription")}
</Text>
) : !widget.valid ? (
<Text size="xs" c="red">
{t("page.list.invalidDefinitionDescription")}
</Text>
) : null}
{widget.sources[0] && (
<Text size="xs" c="dimmed" ff="monospace" lineClamp={1} style={{ wordBreak: "break-all", minWidth: 0 }}>
{widget.sources.map((source) => source.origin).join(" · ")}
</Text>
)}
</Stack>
{widget.migrationRequired ? (
<Tooltip label={t("page.list.migrationInstructions")} multiline maw={420}>
<Badge
color="yellow"
size="sm"
variant="light"
leftSection={<IconSparkles size={12} />}
style={{ flexShrink: 0 }}
>
{t("page.list.migrationRequired")}
</Badge>
</Tooltip>
) : widget.valid ? (
<Badge color="pink" size="sm" variant="light" style={{ flexShrink: 0 }}>
JSX · {widget.requestCount}
</Badge>
) : (
<Tooltip
label={widget.validationIssues
.map((issue) => (issue.path ? `${issue.path}: ${issue.message}` : issue.message))
.join("\n")}
multiline
maw={520}
>
<Badge
color="red"
size="sm"
variant="light"
leftSection={<IconAlertTriangle size={12} />}
style={{ flexShrink: 0 }}
>
{t("page.list.invalidDefinition")}
</Badge>
</Tooltip>
)}
{widget.missingSecrets.length > 0 && (
<Tooltip
label={t("page.list.missingCredentialsDescription", { count: widget.missingSecrets.length })}
multiline
maw={320}
>
<Badge
color="yellow"
size="sm"
variant="light"
leftSection={<IconAlertTriangle size={12} />}
style={{ flexShrink: 0 }}
>
{t("page.list.missingCredentials", { count: widget.missingSecrets.length })}
</Badge>
</Tooltip>
)}
))}
</Group>
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
}
actions={
<Group gap="xs" wrap="nowrap">
{widget.valid && (
<>
<Tooltip label={widget.enabled ? t("action.disable") : t("action.enable")}>
<Switch
size="sm"
checked={widget.enabled}
onChange={handleToggle}
disabled={toggleMutation.isPending}
aria-label={widget.enabled ? t("action.disable") : t("action.enable")}
/>
</Tooltip>
<ActionIcon
component={Link}
href={`/manage/custom-widgets/edit/${widget.id}`}
variant="subtle"
color="gray"
aria-label={t("action.edit")}
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
</>
<Button
component={Link}
href={`/manage/custom-widgets/edit/${widget.id}`}
variant="default"
size="sm"
leftSection={<IconPencil size={16} stroke={1.5} />}
>
{t("action.edit")}
</Button>
)}
<CustomWidgetRowActions
widget={{
@@ -197,7 +103,65 @@ function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
}}
/>
</Group>
</Group>
</Card>
}
/>
);
}
/** Everything that decides whether this widget can actually run right now. */
function CustomWidgetStatusBadges({ widget }: { widget: WidgetDef }) {
const t = useScopedI18n("customWidget");
if (widget.migrationRequired) {
return (
<Tooltip label={t("page.list.migrationInstructions")} multiline maw={420}>
<Badge color="yellow" size="sm" variant="light" leftSection={<IconSparkles size={12} />}>
{t("page.list.migrationRequired")}
</Badge>
</Tooltip>
);
}
if (!widget.valid) {
return (
<Tooltip
label={widget.validationIssues
.map((issue) => (issue.path ? `${issue.path}: ${issue.message}` : issue.message))
.join("\n")}
multiline
maw={520}
>
<Badge color="red" size="sm" variant="light" leftSection={<IconAlertTriangle size={12} />}>
{t("page.list.invalidDefinition")}
</Badge>
</Tooltip>
);
}
return (
<Group gap={6} wrap="nowrap">
{widget.enabled ? (
<Badge color="green" size="sm" variant="light">
{t("page.list.enabled")}
</Badge>
) : (
<Tooltip label={t("page.list.disabledDescription")}>
<Badge color="gray" size="sm" variant="light">
{t("page.list.disabled")}
</Badge>
</Tooltip>
)}
{widget.missingSecrets.length > 0 && (
<Tooltip
label={t("page.list.missingCredentialsDescription", { count: widget.missingSecrets.length })}
multiline
maw={320}
>
<Badge color="yellow" size="sm" variant="light" leftSection={<IconAlertTriangle size={12} />}>
{t("page.list.missingCredentials", { count: widget.missingSecrets.length })}
</Badge>
</Tooltip>
)}
</Group>
);
}
@@ -0,0 +1,38 @@
"use client";
import { Tabs } from "@mantine/core";
import { IconApi, IconBuildingStore } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
const tabs = {
installed: "/manage/custom-widgets",
workshop: "/manage/custom-widgets/workshop",
} as const;
/** Switches between the widgets you have installed and the community Workshop. */
export const CustomWidgetTabs = ({ active }: { active: keyof typeof tabs }) => {
const t = useScopedI18n("customWidget.page.tabs");
return (
<Tabs value={tabs[active]}>
<Tabs.List aria-label={t("ariaLabel")}>
<Tabs.Tab
value={tabs.installed}
leftSection={<IconApi size={16} stroke={1.5} />}
renderRoot={(props) => <Link href={tabs.installed} {...props} />}
>
{t("installed")}
</Tabs.Tab>
<Tabs.Tab
value={tabs.workshop}
leftSection={<IconBuildingStore size={16} stroke={1.5} />}
renderRoot={(props) => <Link href={tabs.workshop} {...props} />}
>
{t("workshop")}
</Tabs.Tab>
</Tabs.List>
</Tabs>
);
};
@@ -10,9 +10,9 @@ import { Link } from "@homarr/ui";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { CustomWidgetBetaBanner } from "./_beta-banner";
import { WorkshopInstallButton } from "~/components/workshop/workshop-install-button";
import { ImportCustomWidgetButton } from "./_import-custom-widget-button";
import { CustomWidgetList } from "./_custom-widget-list";
import { CustomWidgetTabs } from "./_custom-widget-tabs";
import { ImportCustomWidgetButton } from "./_import-custom-widget-button";
export default async function CustomWidgetsPage() {
const session = await auth();
@@ -38,13 +38,13 @@ export default async function CustomWidgetsPage() {
>
{t("action.downloadSkill")}
</MobileAffixButton>
<WorkshopInstallButton />
<ImportCustomWidgetButton />
<MobileAffixButton component={Link} href="/manage/custom-widgets/new" leftSection={<IconPlus size={16} />}>
{t("action.create")}
</MobileAffixButton>
</Group>
}
toolbar={<CustomWidgetTabs active="installed" />}
>
<CustomWidgetBetaBanner />
<CustomWidgetList definitions={definitions} />
@@ -0,0 +1,248 @@
"use client";
import { useEffect, useState } from "react";
import {
Alert,
Box,
Button,
Checkbox,
FileInput,
Group,
Paper,
Stack,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
} from "@mantine/core";
import {
IconArrowLeft,
IconBuildingStore,
IconCheck,
IconExternalLink,
IconPhoto,
IconShieldCheck,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useWorkshopCreateMutation } from "@homarr/workshop/backend";
import { workshopScreenshotsSchema } from "@homarr/workshop/schema";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { ManageStickyFooter } from "~/components/manage/manage-sticky-footer";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { getWorkshopWebUrl } from "~/components/workshop/workshop-client";
import {
getPrivateWorkshopSourceNames,
publishWorkshopDefinition,
serializeWorkshopDefinition,
} from "~/components/workshop/workshop-publish-definition";
import { WorkshopAccountButton, useWorkshopSession } from "~/components/workshop/workshop-session";
const listHref = "/manage/custom-widgets";
export function WorkshopPublishForm({ widget }: { widget: { id: string; name: string } }) {
const t = useScopedI18n("workshop");
const session = useWorkshopSession();
const [title, setTitle] = useState(widget.name);
const [description, setDescription] = useState("");
const [screenshots, setScreenshots] = useState<File[]>([]);
const [sourceUrlsReviewed, setSourceUrlsReviewed] = useState(false);
const [error, setError] = useState<string | null>(null);
const [publishedSubmissionId, setPublishedSubmissionId] = useState<string | null>(null);
const createSubmission = useWorkshopCreateMutation(session.client);
const definition = clientApi.customWidget.export.useQuery({ id: widget.id });
const privateSourceNames = getPrivateWorkshopSourceNames(definition.data);
const definitionFingerprint = definition.data ? serializeWorkshopDefinition(definition.data) : null;
useEffect(() => setSourceUrlsReviewed(false), [definitionFingerprint]);
const breadcrumb = <DynamicBreadcrumb dynamicMappings={new Map([[widget.id, widget.name]])} />;
const publish = async () => {
// Publishing is immediate and public; a second in-flight call would create a
// duplicate listing.
if (createSubmission.isPending) return;
setError(null);
try {
if (!definition.data) throw new Error(t("publish.error"));
if (!workshopScreenshotsSchema.safeParse(screenshots).success) throw new Error(t("publish.invalidScreenshot"));
let submissionId: string | null = null;
const result = await publishWorkshopDefinition({
inspectedDefinition: definition.data,
refetchDefinition: async () => (await definition.refetch()).data,
publish: async (content) => {
const submission = await createSubmission.mutateAsync({
input: { type: "customWidget", title, description, content },
screenshots,
});
submissionId = submission.id;
},
});
if (result === "unavailable") throw new Error(t("publish.error"));
if (result === "changed") {
setSourceUrlsReviewed(false);
setError(t("publish.definitionChanged"));
return;
}
if (!submissionId) throw new Error(t("publish.error"));
setPublishedSubmissionId(submissionId);
} catch (cause) {
setError(cause instanceof Error ? cause.message : t("publish.error"));
}
};
if (publishedSubmissionId) {
return (
<ManagePageLayout title={t("publish.successTitle")} breadcrumb={breadcrumb}>
<Paper withBorder radius="md" p="xl">
<Stack align="center" gap="lg">
<ThemeIcon size={64} radius="xl" color="green" variant="light">
<IconCheck size={32} stroke={2} />
</ThemeIcon>
<Text c="dimmed" ta="center" maw={520}>
{t("publish.success")}
</Text>
<Text c="dimmed" size="sm" ta="center" maw={520}>
{t("publish.manageDescription")}
</Text>
<Group gap="sm">
<Button component={Link} href={listHref} variant="default" leftSection={<IconArrowLeft size={16} />}>
{t("publish.done")}
</Button>
<Button
component="a"
href={getWorkshopWebUrl(publishedSubmissionId)}
target="_blank"
rel="noopener noreferrer"
rightSection={<IconExternalLink size={16} />}
>
{t("publish.viewSubmission")}
</Button>
</Group>
</Stack>
</Paper>
</ManagePageLayout>
);
}
const blocked =
!session.user ||
createSubmission.isPending ||
!definition.data ||
definition.isError ||
title.trim().length < 3 ||
(privateSourceNames.length > 0 && !sourceUrlsReviewed);
return (
<ManagePageLayout
title={
<Stack gap={2}>
<Title order={1}>{t("publish.title")}</Title>
<Text size="sm" c="dimmed">
{widget.name}
</Text>
</Stack>
}
breadcrumb={breadcrumb}
primaryAction={<WorkshopAccountButton session={session} />}
>
<Stack gap="md">
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-default-hover)">
<Group wrap="nowrap" align="flex-start">
<ThemeIcon variant="light" radius="md" size="lg">
<IconBuildingStore size={20} />
</ThemeIcon>
<Box>
<Text fw={600}>{t("publish.introTitle")}</Text>
<Text c="dimmed" size="sm" mt={2}>
{t("publish.introDescription")}
</Text>
</Box>
</Group>
</Paper>
{!session.user && <Alert color="blue">{t("publish.signInHint")}</Alert>}
<Paper withBorder radius="md" p="md">
<Stack gap="md">
<Box>
<Text fw={600} size="sm">
{t("publish.listingDetails")}
</Text>
<Text c="dimmed" size="xs" mt={2}>
{t("publish.listingDetailsDescription")}
</Text>
</Box>
<TextInput
label={t("publish.titleField")}
value={title}
onChange={(event) => setTitle(event.currentTarget.value)}
required
/>
<Textarea
label={t("publish.descriptionField")}
value={description}
onChange={(event) => setDescription(event.currentTarget.value)}
minRows={4}
autosize
maxRows={8}
/>
<FileInput
label={t("publish.screenshots")}
description={t("publish.screenshotsDescription")}
placeholder={t("publish.screenshotsPlaceholder")}
leftSection={<IconPhoto size={16} />}
accept="image/png,image/jpeg,image/webp"
multiple
value={screenshots}
onChange={setScreenshots}
clearable
/>
</Stack>
</Paper>
{privateSourceNames.length > 0 && (
<Alert color="yellow" icon={<IconShieldCheck size={18} />} title={t("publish.reviewSourcesTitle")}>
{t("publish.privateSourceWarning", {
sources: privateSourceNames.join(", "),
})}
<Checkbox
mt="sm"
checked={sourceUrlsReviewed}
onChange={(event) => setSourceUrlsReviewed(event.currentTarget.checked)}
label={t("publish.privateSourceConfirmation")}
/>
</Alert>
)}
{definition.isError && <Alert color="red">{t("publish.error")}</Alert>}
{error && <Alert color="red">{error}</Alert>}
<ManageStickyFooter
secondary={
<Text c="dimmed" size="xs" maw={420}>
{t("publish.publicationNote")}
</Text>
}
>
<Button component={Link} href={listHref} variant="default">
{t("publish.cancel")}
</Button>
<Button
size="md"
loading={createSubmission.isPending || definition.isFetching}
disabled={blocked}
onClick={() => void publish()}
>
{t("publish.action")}
</Button>
</ManageStickyFooter>
</Stack>
</ManagePageLayout>
);
}
@@ -0,0 +1,21 @@
import { redirect } from "next/navigation";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
import { WorkshopPublishForm } from "./_workshop-publish-form";
interface WorkshopPublishPageProps {
params: Promise<{ id: string }>;
}
export default async function WorkshopPublishPage(props: WorkshopPublishPageProps) {
const session = await auth();
if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login");
const { id } = await props.params;
const definition = await api.customWidget.get({ id }).catch(catchTrpcNotFound);
return <WorkshopPublishForm widget={{ id, name: definition.name }} />;
}
@@ -0,0 +1,284 @@
"use client";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import {
Accordion,
Alert,
Badge,
Box,
Button,
Card,
Group,
Paper,
Skeleton,
Stack,
Text,
Title,
Tooltip,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconAlertTriangle, IconArrowLeft, IconDownload, IconExternalLink, IconFlag } from "@tabler/icons-react";
import { CUSTOM_WIDGET_SCHEMA } from "@homarr/custom-widgets/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { CustomWidgetSourceSetupPanel, ImportReviewContent } from "@homarr/custom-widgets/workbench";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useWorkshopReportSummariesQuery, useWorkshopSubmissionQuery } from "@homarr/workshop/backend";
import type { WorkshopSubmissionDetail } from "@homarr/workshop/schema";
import { validateWorkshopWidget, workshopExportFilename } from "@homarr/workshop/schema";
import { useCustomWidgetImport } from "~/components/custom-widgets/use-custom-widget-import";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { ManageStickyFooter } from "~/components/manage/manage-sticky-footer";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { WorkshopCodeViewer } from "~/components/workshop/workshop-code-viewer";
import { getWorkshopWebUrl } from "~/components/workshop/workshop-client";
import { WorkshopReportModal } from "~/components/workshop/workshop-report-modal";
import { WorkshopScreenshots } from "~/components/workshop/workshop-screenshots";
import { useWorkshopSession } from "~/components/workshop/workshop-session";
import { WorkshopVoteControl } from "~/components/workshop/workshop-vote-control";
const browseHref = "/manage/custom-widgets/workshop";
const installedHref = "/manage/custom-widgets";
export function WorkshopDetail({ id }: { id: string }) {
const t = useScopedI18n("workshop");
const router = useRouter();
const session = useWorkshopSession();
const [reportOpened, reportControls] = useDisclosure(false);
const detail = useWorkshopSubmissionQuery(session.client, id);
const reportSummaries = useWorkshopReportSummariesQuery(session.client, detail.data?.reportCount ? id : "");
const content = detail.data?.content;
const validation = useMemo(() => (content ? validateWorkshopWidget(content) : null), [content]);
const widget = useMemo<HomarrCustomWidgetV2 | null>(
() => (validation?.success && typeof validation.data !== "string" ? validation.data : null),
[validation],
);
const compatible =
detail.data?.type === "customWidget" && widget !== null && detail.data.widgetSchema === CUSTOM_WIDGET_SCHEMA;
const importer = useCustomWidgetImport({
widget: compatible ? widget : null,
// Straight back to the installed list: the edit form is heavy to load and the
// widget is usable as-is once its sources are configured here.
onImported: () => router.push(installedHref),
});
const breadcrumb = (
<DynamicBreadcrumb dynamicMappings={detail.data ? new Map([[id, detail.data.title]]) : undefined} />
);
if (detail.isPending) {
return (
<ManagePageLayout title={<Skeleton h={36} w={280} />} breadcrumb={breadcrumb}>
<Skeleton h={420} radius="md" />
</ManagePageLayout>
);
}
if (detail.isError || !detail.data) {
return (
<ManagePageLayout title={t("title")} breadcrumb={breadcrumb}>
<Alert color="red" icon={<IconAlertTriangle size={18} />} title={t("loadError")}>
<Stack gap="sm" align="flex-start">
<Text size="sm">{detail.error instanceof Error ? detail.error.message : t("unavailable")}</Text>
<Button
component={Link}
href={browseHref}
variant="light"
size="xs"
leftSection={<IconArrowLeft size={14} />}
>
{t("back")}
</Button>
</Stack>
</Alert>
</ManagePageLayout>
);
}
const submission = detail.data;
return (
<ManagePageLayout
title={
<Stack gap={2}>
<Title order={1}>{submission.title}</Title>
<Text size="sm" c="dimmed">
{t("author", {
name: submission.authorName || t("communityMember"),
})}
</Text>
</Stack>
}
breadcrumb={breadcrumb}
primaryAction={
<Group gap="xs" wrap="nowrap">
<Button component={Link} href={browseHref} variant="default" leftSection={<IconArrowLeft size={16} />}>
{t("back")}
</Button>
<Button
component="a"
href={getWorkshopWebUrl(submission.id)}
target="_blank"
rel="noopener noreferrer"
variant="subtle"
color="gray"
leftSection={<IconExternalLink size={16} />}
visibleFrom="sm"
>
{t("openCommunity")}
</Button>
</Group>
}
>
<Stack gap="md">
{submission.outdated && (
<Alert color="yellow" icon={<IconAlertTriangle size={18} />}>
{t("outdatedWarning")}
</Alert>
)}
{submission.reportCount > 0 && (
<Stack gap="xs">
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
{t("reportWarning", { count: submission.reportCount })}
</Alert>
{reportSummaries.data?.map((summary) => (
<Card key={summary.id} withBorder radius="md" 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>
)}
{!compatible && (
<Alert color="red" icon={<IconAlertTriangle size={18} />} title={t("installError")}>
{t("installErrorDescription")}
{validation && !validation.success ? ` ${validation.error}` : ""}
</Alert>
)}
{submission.description && <Text>{submission.description}</Text>}
<WorkshopScreenshots
client={session.client}
submissionId={submission.id}
title={submission.title}
screenshots={submission.screenshots}
/>
{compatible && importer.review && (
<Paper withBorder radius="md" p="md">
<Stack gap="md">
<Box>
<Text fw={600}>{t("installReviewTitle")}</Text>
<Text size="sm" c="dimmed">
{t("securityNotice")}
</Text>
</Box>
<ImportReviewContent review={importer.review} messages={importer.reviewMessages}>
<CustomWidgetSourceSetupPanel
setups={importer.setups}
values={importer.values}
onChange={importer.setValue}
messages={importer.setupMessages}
/>
</ImportReviewContent>
</Stack>
</Paper>
)}
<Accordion variant="contained" radius="md">
<Accordion.Item value="technical-details">
<Accordion.Control>{t("technicalDetails")}</Accordion.Control>
<Accordion.Panel>
<WorkshopCodeViewer
value={submission.content}
language={submission.type === "customCss" ? "css" : "json"}
/>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
<ManageStickyFooter
secondary={
<>
<WorkshopVoteControl
client={session.client}
submissionId={submission.id}
score={submission.score}
canVote={session.user !== null}
/>
<Button
variant="subtle"
color="gray"
size="compact-sm"
leftSection={<IconDownload size={16} />}
onClick={() => downloadSubmission(submission)}
>
{t("export")}
</Button>
{/* Wrapped: a tooltip on the disabled button itself would never fire. */}
<Tooltip label={session.user ? t("report") : t("signInHint")}>
<span>
<Button
variant="subtle"
color="red"
size="compact-sm"
leftSection={<IconFlag size={16} />}
disabled={!session.user}
onClick={reportControls.open}
>
{t("report")}
</Button>
</span>
</Tooltip>
</>
}
>
<Button
size="md"
loading={importer.pending || importer.succeeded}
disabled={!compatible || !importer.ready || importer.succeeded}
onClick={importer.importWidget}
>
{t("install")}
</Button>
</ManageStickyFooter>
</Stack>
<WorkshopReportModal
client={session.client}
submissionId={submission.id}
opened={reportOpened}
onClose={reportControls.close}
/>
</ManagePageLayout>
);
}
function downloadSubmission(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);
}
@@ -0,0 +1,18 @@
import { redirect } from "next/navigation";
import { auth } from "@homarr/auth/next";
import { WorkshopDetail } from "./_workshop-detail";
interface WorkshopDetailPageProps {
params: Promise<{ id: string }>;
}
export default async function WorkshopDetailPage(props: WorkshopDetailPageProps) {
const session = await auth();
if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login");
const { id } = await props.params;
return <WorkshopDetail id={id} />;
}
@@ -0,0 +1,155 @@
"use client";
import { useCallback } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { ActionIcon, Button, Group, Select, Stack, Tooltip } from "@mantine/core";
import { IconBuildingStore, IconExternalLink } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { Link, SearchInput, TablePagination } from "@homarr/ui";
import { useWorkshopQuery } from "@homarr/workshop/backend";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { NoResults } from "~/components/no-results";
import { getWorkshopWebUrl } from "~/components/workshop/workshop-client";
import { WorkshopAccountButton, useWorkshopSession } from "~/components/workshop/workshop-session";
import { WorkshopSubmissionGrid } from "~/components/workshop/workshop-submission-grid";
import { WorkshopVoteControl } from "~/components/workshop/workshop-vote-control";
import { CustomWidgetTabs } from "../_custom-widget-tabs";
export const WORKSHOP_PAGE_SIZE = 12;
interface WorkshopBrowseProps {
search: string | undefined;
sort: "top" | "newest";
page: number;
}
export function WorkshopBrowse({ search, sort, page }: WorkshopBrowseProps) {
const t = useScopedI18n("workshop");
const session = useWorkshopSession();
const query = useWorkshopQuery(session.client, {
page,
perPage: WORKSHOP_PAGE_SIZE,
type: "customWidget",
sort,
search,
});
const totalPages = query.data?.totalPages ?? 1;
return (
<ManagePageLayout
title={t("title")}
primaryAction={
<Group gap="xs" wrap="nowrap">
<WorkshopAccountButton session={session} />
<Button
component="a"
href={getWorkshopWebUrl()}
target="_blank"
rel="noopener noreferrer"
variant="default"
leftSection={<IconExternalLink size={16} />}
visibleFrom="sm"
>
{t("openCommunity")}
</Button>
</Group>
}
toolbar={
<Stack gap="sm">
<CustomWidgetTabs active="workshop" />
<Group gap="sm" align="flex-end" wrap="nowrap">
<SearchInput placeholder={`${t("search")}...`} ariaLabel={t("search")} defaultValue={search} flexExpand />
<WorkshopSortSelect value={sort} />
</Group>
</Stack>
}
footer={totalPages > 1 ? <TablePagination total={totalPages} /> : undefined}
>
<WorkshopSubmissionGrid
client={session.client}
query={query}
ariaLabel={t("listAriaLabel")}
emptyState={
search ? (
<NoResults
icon={IconBuildingStore}
title={t("emptyFilteredTitle")}
description={t("emptyFilteredDescription", { search })}
action={{
label: t("clearSearch"),
href: "/manage/custom-widgets/workshop",
}}
/>
) : (
<NoResults icon={IconBuildingStore} title={t("empty")} description={t("emptyDescription")} />
)
}
renderActions={(item) => (
<>
<WorkshopVoteControl
client={session.client}
submissionId={item.id}
score={item.score}
canVote={session.user !== null}
size="sm"
/>
<Group gap={4} wrap="nowrap">
<Tooltip label={t("openCommunity")}>
<ActionIcon
component="a"
href={getWorkshopWebUrl(item.id)}
target="_blank"
rel="noopener noreferrer"
variant="subtle"
color="gray"
size="lg"
aria-label={t("openOnWorkshop", { title: item.title })}
>
<IconExternalLink size={16} stroke={1.5} />
</ActionIcon>
</Tooltip>
<Button component={Link} href={`/manage/custom-widgets/workshop/${item.id}`} size="sm">
{t("install")}
</Button>
</Group>
</>
)}
/>
</ManagePageLayout>
);
}
function WorkshopSortSelect({ value }: { value: "top" | "newest" }) {
const t = useScopedI18n("workshop");
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const handleChange = useCallback(
(next: string | null) => {
const params = new URLSearchParams(searchParams);
if (next === "newest") params.set("sort", next);
else params.delete("sort");
params.delete("page");
const query = params.toString();
router.replace(query ? `${pathname}?${query}` : pathname);
},
[pathname, router, searchParams],
);
return (
<Select
aria-label={t("sort")}
value={value}
onChange={handleChange}
allowDeselect={false}
w={160}
data={[
{ value: "top", label: t("sortTop") },
{ value: "newest", label: t("sortNewest") },
]}
/>
);
}
@@ -0,0 +1,31 @@
import { redirect } from "next/navigation";
import { z } from "zod/v4";
import { auth } from "@homarr/auth/next";
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { WorkshopBrowse } from "./_workshop-browse";
const searchParamsSchema = z.object({
search: z.string().optional(),
sort: z.enum(["top", "newest"]).catch("top"),
page: z
.string()
.regex(/^[1-9]\d*$/u)
.transform(Number)
.pipe(z.number().int().positive())
.catch(1),
});
interface WorkshopBrowsePageProps {
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
}
export default async function WorkshopBrowsePage(props: WorkshopBrowsePageProps) {
const session = await auth();
if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login");
const { search, sort, page } = searchParamsSchema.parse(await props.searchParams);
return <WorkshopBrowse search={search?.trim() || undefined} sort={sort} page={page} />;
}
@@ -5,7 +5,6 @@ import {
IconApi,
IconBook2,
IconBox,
IconBuildingStore,
IconBrandDiscord,
IconBrandDocker,
IconBrandGithub,
@@ -102,12 +101,6 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
label: t("items.customWidgets"),
hidden: !session?.user.permissions.includes("admin"),
},
{
icon: IconBuildingStore,
href: "/manage/workshop",
label: t("items.workshop"),
hidden: !session?.user.permissions.includes("admin"),
},
{
icon: IconSearch,
href: "/manage/search-engines",
@@ -1,46 +1,6 @@
import { redirect } from "next/navigation";
import { Alert, Button, Stack } from "@mantine/core";
import { IconExternalLink, IconInfoCircle } from "@tabler/icons-react";
import { permanentRedirect } from "next/navigation";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { WorkshopInstaller } from "~/components/workshop/workshop-installer";
import { env } from "~/env";
export default async function WorkshopPage() {
const session = await auth();
if (!session?.user.permissions.includes("admin")) redirect(session ? "/" : "/auth/login");
const t = await getScopedI18n("workshop");
const { workshopWebUrl } = resolveHomarrUrlConfig({
homarrWebsiteUrl: env.HOMARR_WEBSITE_URL,
workshopApiUrl: env.WORKSHOP_API_URL,
workshopWebUrl: env.WORKSHOP_WEB_URL,
});
return (
<ManagePageLayout
title={t("title")}
primaryAction={
<Button
component="a"
href={workshopWebUrl}
target="_blank"
rel="noopener noreferrer"
variant="default"
leftSection={<IconExternalLink size={16} />}
>
{t("openCommunity")}
</Button>
}
>
<Stack gap="lg">
<Alert icon={<IconInfoCircle size={18} />} title={t("manageSubmissionsTitle")} color="blue">
{t("manageSubmissionsDescription")}
</Alert>
<WorkshopInstaller />
</Stack>
</ManagePageLayout>
);
/** The Workshop now lives as a tab of the Custom Widgets page. */
export default function WorkshopPage(): never {
permanentRedirect("/manage/custom-widgets/workshop");
}
+10 -6
View File
@@ -4,7 +4,7 @@ import path from "path";
import { NextResponse } from "next/server";
import AdmZip from "adm-zip";
import Database from "better-sqlite3";
import BetterSqlite3 from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
@@ -18,6 +18,9 @@ import { env } from "@homarr/common/env";
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { db } from "@homarr/db";
import type { Database } from "@homarr/db";
import { applyCustomMigrationsAsync } from "@homarr/db/migrations/custom";
import { schema } from "@homarr/db/schema";
import { findMigrationsFolder } from "../shared";
@@ -120,7 +123,7 @@ const assertArchiveSizeLimits = (zip: AdmZip) => {
}
};
const reEncryptSecrets = (tempDb: InstanceType<typeof Database>, importedKeyHex: string) => {
const reEncryptSecrets = (tempDb: InstanceType<typeof BetterSqlite3>, importedKeyHex: string) => {
const currentKeyHex = env.SECRET_ENCRYPTION_KEY;
if (importedKeyHex === currentKeyHex) return;
@@ -172,7 +175,7 @@ const reEncryptSecrets = (tempDb: InstanceType<typeof Database>, importedKeyHex:
transaction();
};
const getHomeBoardName = (tempDb: InstanceType<typeof Database>): string | null => {
const getHomeBoardName = (tempDb: InstanceType<typeof BetterSqlite3>): string | null => {
const row = tempDb
.prepare(
`SELECT "name" FROM (
@@ -234,7 +237,7 @@ export async function POST(req: Request) {
restoreInProgress = true;
let tempDirectory: string | null = null;
let tempDb: InstanceType<typeof Database> | null = null;
let tempDb: InstanceType<typeof BetterSqlite3> | null = null;
try {
let formData: FormData;
@@ -285,9 +288,10 @@ export async function POST(req: Request) {
}
fs.writeFileSync(tempPath, dbEntry.getData());
tempDb = new Database(tempPath);
const drizzleDb = drizzle(tempDb, { casing: DB_CASING });
tempDb = new BetterSqlite3(tempPath);
const drizzleDb = drizzle(tempDb, { casing: DB_CASING, schema });
migrate(drizzleDb, { migrationsFolder });
await applyCustomMigrationsAsync(drizzleDb as unknown as Database);
const metadataEntry = zip.getEntry("metadata.json");
if (!metadataEntry) {
+2 -3
View File
@@ -1,12 +1,11 @@
import fs from "fs";
import path from "path";
export const findMigrationsFolder = () => {
const cwd = process.cwd();
export const findMigrationsFolder = (cwd = process.cwd()) => {
const migrationsFolderCandidates = [
path.join(cwd, "db/migrations/sqlite"),
path.join(cwd, "packages/db/migrations/sqlite"),
path.resolve(cwd, "../../db/migrations/sqlite"),
path.resolve(cwd, "../../packages/db/migrations/sqlite"),
"/app/db/migrations/sqlite",
];
return migrationsFolderCandidates.find((candidate) => fs.existsSync(candidate));
@@ -2,11 +2,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getStepAfterRestorePreview, waitForServerReadinessAsync } from "./database-restore-flow";
it("requires confirmation after every restore preview", () => {
expect(getStepAfterRestorePreview()).toBe("confirm");
});
import { waitForServerReadinessAsync } from "./database-restore-flow";
describe("waitForServerReadinessAsync", () => {
beforeEach(() => {
@@ -29,8 +29,6 @@ const RESTART_REQUEST_TIMEOUT_MS = 5_000;
export type ServerReadinessResult = "ready" | "timedOut" | "aborted";
export const getStepAfterRestorePreview = (): RestoreStep => "confirm";
interface WaitForServerReadinessOptions {
restartAfterMs: number;
signal: AbortSignal;
@@ -256,14 +254,14 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap" miw={0}>
<IconFileZip size={20} />
<div style={{ minWidth: 0 }}>
<Group>
<Text size="sm" fw={500} truncate>
{file?.name}
</Text>
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{file ? `${(file.size / 1024 / 1024).toFixed(2)} MB` : ""}
</Text>
</div>
</Group>
</Group>
<Button variant="subtle" size="xs" onClick={handleClear} style={{ flexShrink: 0 }}>
{t("changeFile")}
@@ -291,8 +289,11 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
<>
<BackupPreviewPanel analysis={analysis} />
<Group justify="flex-end">
<Button rightSection={<IconArrowRight size={16} />} onClick={() => setStep(getStepAfterRestorePreview())}>
{t("continueToRestore")}
<Button
rightSection={<IconArrowRight size={16} />}
onClick={() => (variant === "standalone" ? void handleConfirm() : setStep("confirm"))}
>
{variant === "standalone" ? t("confirm.submit") : t("continueToRestore")}
</Button>
</Group>
</>
@@ -15,7 +15,7 @@ import {
Tooltip,
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { IconApi } from "@tabler/icons-react";
import { IconApi, IconBuildingStore } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
@@ -41,7 +41,6 @@ import type { TablerIcon } from "@homarr/ui";
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import { WorkshopInstallButton } from "~/components/workshop/workshop-install-button";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { useSetupAnalytics } from "~/components/create/setup-analytics";
import type { EmptySection } from "~/app/[locale]/boards/_types";
@@ -506,7 +505,18 @@ const ItemSelectModalContent = ({
))}
<Box style={{ gridColumn: "1 / -1" }}>
<WorkshopInstallButton fullWidth>{t("workshop.installDialog")}</WorkshopInstallButton>
{/* Opens in a new tab so unsaved board changes survive the detour. */}
<Button
component="a"
href="/manage/custom-widgets/workshop"
target="_blank"
rel="noopener noreferrer"
variant="default"
fullWidth
leftSection={<IconBuildingStore size={16} />}
>
{t("workshop.browseWorkshop")}
</Button>
</Box>
</>
)}
@@ -1,27 +1,13 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import type { ModalProps } from "@mantine/core";
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 type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { CustomWidgetSourceSetupPanel, ImportReviewDialog } from "@homarr/custom-widgets/workbench";
import { useScopedI18n } from "@homarr/translation/client";
import { useCustomWidgetImport } from "./use-custom-widget-import";
interface CustomWidgetImportDialogProps {
opened: boolean;
widget: HomarrCustomWidgetV2 | null;
@@ -29,7 +15,12 @@ interface CustomWidgetImportDialogProps {
stackId?: string;
zIndex?: number;
modalProps?: Pick<ModalProps, "size" | "styles">;
labels?: { title?: string; description?: string; cancel?: string; confirm?: string };
labels?: {
title?: string;
description?: string;
cancel?: string;
confirm?: string;
};
onClose(): void;
onImported?(result: { id: string }): void;
}
@@ -46,62 +37,14 @@ export function CustomWidgetImportDialog({
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 onSuccess = (result: { id: string }) => {
showSuccessNotification({
title: legacyId ? t("action.migrate") : t("action.import"),
message: legacyId ? t("notification.migrated") : t("notification.imported"),
});
void utils.customWidget.list.invalidate();
void utils.customWidget.available.invalidate();
void utils.widget.customApi.getData.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets").then(() => router.refresh());
onImported?.(result);
onClose();
};
const onError = (error: { message?: string }) => {
showErrorNotification({
title: legacyId ? t("action.migrate") : t("action.import"),
message: error.message || (legacyId ? t("notification.migrationError") : t("notification.importError")),
});
};
const importMutation = clientApi.customWidget.import.useMutation({ onSuccess, onError });
const migrateMutation = clientApi.customWidget.migrateLegacy.useMutation({ onSuccess, onError });
const pending = importMutation.isPending || migrateMutation.isPending;
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 }] : [],
),
);
if (legacyId) migrateMutation.mutate({ id: legacyId, widget: configuredWidget, secrets });
else importMutation.mutate({ widget: configuredWidget, secrets });
};
const importer = useCustomWidgetImport({
widget,
legacyId,
onImported: (result) => {
onImported?.(result);
onClose();
},
});
return (
<ImportReviewDialog
@@ -109,49 +52,26 @@ export function CustomWidgetImportDialog({
stackId={stackId}
zIndex={zIndex}
{...modalProps}
review={review}
pending={pending}
confirmDisabled={!isCustomWidgetSourceSetupReady(setups, values)}
review={importer.review}
pending={importer.pending}
confirmDisabled={!importer.ready}
onClose={onClose}
onConfirm={importWidget}
onConfirm={importer.importWidget}
messages={{
title: legacyId ? t("importReview.migrationTitle") : (labels?.title ?? t("importReview.title")),
...importer.reviewMessages,
description: legacyId
? t("importReview.migrationDescription")
: (labels?.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"),
: (labels?.description ?? importer.reviewMessages.description),
title: legacyId ? t("importReview.migrationTitle") : (labels?.title ?? t("importReview.title")),
cancel: labels?.cancel ?? t("importReview.cancel"),
confirm: legacyId ? t("importReview.confirmMigration") : (labels?.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}`),
}}
setups={importer.setups}
values={importer.values}
onChange={importer.setValue}
messages={importer.setupMessages}
/>
</ImportReviewDialog>
);
@@ -0,0 +1,9 @@
export const downloadJson = (data: unknown, filename: string) => {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
};
@@ -0,0 +1,167 @@
"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, isCustomWidgetSourceSetupReady } from "@homarr/custom-widgets/workbench";
import type {
CustomWidgetSourceSetupMessages,
CustomWidgetSourceSetupValue,
ImportReviewContentMessages,
} from "@homarr/custom-widgets/workbench";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
interface UseCustomWidgetImportOptions {
widget: HomarrCustomWidgetV2 | null;
/** Set when replacing an existing v1 widget instead of creating a new one. */
legacyId?: string;
onImported?(result: { id: string }): void;
}
/**
* The single import pipeline behind every "add this custom widget" surface:
* source setup, security review and the mutation itself. Keeping it in one hook
* means the Workshop page and the file/clipboard dialog can never drift apart.
*/
export function useCustomWidgetImport({ widget, legacyId, onImported }: UseCustomWidgetImportOptions) {
const t = useScopedI18n("customWidget");
const router = useRouter();
const utils = clientApi.useUtils();
// Keyed on the sources themselves rather than the widget object: a background
// refetch (window focus, or the invalidation a vote triggers) hands us a new but
// identical object, and resetting on that would silently wipe credentials the
// user is part-way through typing.
const sourcesKey = JSON.stringify(widget?.sources ?? {});
const setups = useMemo(
() => getCustomWidgetSourceSetups(widget?.sources ?? {}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[sourcesKey],
);
const [values, setValues] = useState<Record<string, CustomWidgetSourceSetupValue>>({});
useEffect(() => setValues(createCustomWidgetSourceSetupValues(setups)), [setups]);
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]);
// A widget must only ever be imported once per visit: the mutation creates a new
// record every call, so a second click would silently duplicate it.
const [succeeded, setSucceeded] = useState(false);
useEffect(() => setSucceeded(false), [widget]);
const onSuccess = (result: { id: string }) => {
setSucceeded(true);
showSuccessNotification({
title: legacyId ? t("action.migrate") : t("action.import"),
message: legacyId ? t("notification.migrated") : t("notification.imported"),
});
void utils.customWidget.list.invalidate();
void utils.customWidget.available.invalidate();
void utils.widget.customApi.getData.invalidate();
// Hand over to the caller before the server-side revalidation round-trip so
// navigation is immediate rather than waiting on it.
onImported?.(result);
void revalidatePathActionAsync("/manage/custom-widgets").then(() => router.refresh());
};
const onError = (error: { message?: string }) => {
showErrorNotification({
title: legacyId ? t("action.migrate") : t("action.import"),
message: error.message || (legacyId ? t("notification.migrationError") : t("notification.importError")),
});
};
const importMutation = clientApi.customWidget.import.useMutation({
onSuccess,
onError,
});
const migrateMutation = clientApi.customWidget.migrateLegacy.useMutation({
onSuccess,
onError,
});
const pending = importMutation.isPending || migrateMutation.isPending;
const importWidget = () => {
if (!configuredWidget || pending || succeeded) 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 }] : [],
),
);
if (legacyId)
migrateMutation.mutate({
id: legacyId,
widget: configuredWidget,
secrets,
});
else importMutation.mutate({ widget: configuredWidget, secrets });
};
const reviewMessages: ImportReviewContentMessages = {
description: legacyId ? t("importReview.migrationDescription") : 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"),
permission: (permission) => t(`preview.request.permission.${permission}` as never),
};
const setupMessages: CustomWidgetSourceSetupMessages = {
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}`),
};
return {
review,
setups,
values,
setValue: (sourceId: string, value: CustomWidgetSourceSetupValue) =>
setValues((current) => ({ ...current, [sourceId]: value })),
ready: isCustomWidgetSourceSetupReady(setups, values),
pending,
/** True once the import went through; callers keep the action locked while navigating away. */
succeeded,
importWidget,
reviewMessages,
setupMessages,
};
}
@@ -19,16 +19,17 @@ export const ManageCollectionPage = ({
...layoutProps
}: ManageCollectionPageProps) => (
<ManagePageLayout {...layoutProps}>
{itemCount === 0 ? (
emptyState
) : (
<Stack component="ul" aria-label={ariaLabel} gap="sm" m={0} p={0} style={{ listStyle: "none" }}>
{children}
</Stack>
)}
{itemCount === 0 ? emptyState : <ManageCollectionList ariaLabel={ariaLabel}>{children}</ManageCollectionList>}
</ManagePageLayout>
);
/** The `ul` wrapper used by every manage list, so collections rendered outside `ManageCollectionPage` still match. */
export const ManageCollectionList = ({ ariaLabel, children }: { ariaLabel: string; children: ReactNode }) => (
<Stack component="ul" aria-label={ariaLabel} gap="sm" m={0} p={0} style={{ listStyle: "none" }}>
{children}
</Stack>
);
export interface ManageCollectionItemProps {
leading?: ReactNode;
title: ReactNode;
@@ -8,6 +8,8 @@ import { MANAGE_FLOATING_ACTION_BOTTOM_OFFSET } from "./manage-page.constants";
export interface ManagePageLayoutProps {
title: ReactNode;
/** Overrides the default breadcrumb, e.g. to map a dynamic route segment onto a readable name. */
breadcrumb?: ReactNode;
primaryAction?: ReactNode;
toolbar?: ReactNode;
footer?: ReactNode;
@@ -18,6 +20,7 @@ export interface ManagePageLayoutProps {
export const ManagePageLayout = ({
title,
breadcrumb,
primaryAction,
toolbar,
footer,
@@ -29,7 +32,7 @@ export const ManagePageLayout = ({
return (
<ManageContainer size={size}>
<DynamicBreadcrumb />
{breadcrumb ?? <DynamicBreadcrumb />}
<Stack pb={floatingPrimaryAction ? { base: MANAGE_FLOATING_ACTION_BOTTOM_OFFSET, md: 0 } : undefined}>
<Group justify="space-between" align="center">
{titleNode}
@@ -0,0 +1,35 @@
import type { ReactNode } from "react";
import { Group, Paper } from "@mantine/core";
export interface ManageStickyFooterProps {
/** Secondary controls, pinned to the start of the bar. */
secondary?: ReactNode;
/** The primary action(s) of the page, pinned to the end of the bar. */
children: ReactNode;
}
/**
* A bar that stays visible at the bottom of the viewport while the page scrolls,
* so the primary action of a long page is always one click away.
*/
export const ManageStickyFooter = ({ secondary, children }: ManageStickyFooterProps) => (
<Paper
withBorder
shadow="md"
radius="md"
p="sm"
pos="sticky"
bottom="var(--mantine-spacing-md)"
bg="var(--mantine-color-body)"
style={{ zIndex: 2 }}
>
<Group justify="space-between" align="center" gap="sm" wrap="wrap">
<Group gap="xs" wrap="wrap">
{secondary}
</Group>
<Group gap="sm" wrap="nowrap" ml="auto">
{children}
</Group>
</Group>
</Paper>
);
@@ -0,0 +1,17 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
describe("DynamicBreadcrumb", () => {
it("falls back to the raw segment when a path segment has no translation", () => {
const source = readFileSync(
`${process.cwd()}/apps/nextjs/src/components/navigation/dynamic-breadcrumb.tsx`,
"utf8",
);
// Dynamic ids (e.g. /manage/custom-widgets/workshop/<id>) have no message and are
// not mapped while their page loads; resolving them must not raise MISSING_MESSAGE.
expect(source).toContain("t.has(labelKey)");
expect(source).toContain("mappedValue ?? (t.has(labelKey) ? t(labelKey) : pathnamePart)");
expect(source).not.toMatch(/\{t\(`\$\{translationKey\}\.label`/u);
});
});
@@ -65,24 +65,19 @@ export const DynamicBreadcrumb = ({
const isNonInteractable =
nonInteractable?.includes(pathnamePart) === true || categorySegments.has(pathnamePart);
if (mappedValue) {
if (isNonInteractable) {
return <Text key={href}>{mappedValue}</Text>;
}
return (
<Anchor key={href} href={href}>
{mappedValue}
</Anchor>
);
}
// Dynamic segments (ids) have no translation and may not be mapped yet while
// their page is still loading. Fall back to the raw segment instead of
// raising MISSING_MESSAGE.
const labelKey = `${translationKey}.label` as TranslationKeys;
const label = mappedValue ?? (t.has(labelKey) ? t(labelKey) : pathnamePart);
if (isNonInteractable) {
return <Text key={href}>{t(`${translationKey}.label` as TranslationKeys)}</Text>;
return <Text key={href}>{label}</Text>;
}
return (
<Anchor key={href} href={href}>
{t(`${translationKey}.label` as TranslationKeys)}
{label}
</Anchor>
);
})}
@@ -1,637 +0,0 @@
"use client";
import { useEffect, useId, 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 type { ModalProps } from "@mantine/core";
import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
import {
IconAlertTriangle,
IconArrowRight,
IconBuildingStore,
IconDownload,
IconExternalLink,
IconFlag,
IconRefresh,
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 { ReadOnlyCustomWidgetCode } from "@homarr/custom-widgets/workbench";
import type { CustomWidgetEditorMessages } from "@homarr/custom-widgets/workbench";
import {
useWorkshopCssImportMutation,
useWorkshopQuery,
useWorkshopReportSummariesQuery,
useWorkshopReportMutation,
useWorkshopSubmissionQuery,
useWorkshopVoteMutation,
useWorkshopWidgetImportMutation,
} from "@homarr/workshop/backend";
import type {
WorkshopReport,
WorkshopSubmissionDetail,
WorkshopSubmissionType,
WorkshopUser,
} from "@homarr/workshop/schema";
import {
validateWorkshopContent,
validateWorkshopWidget,
WORKSHOP_CSS_SCHEMA,
workshopExportFilename,
} from "@homarr/workshop/schema";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { createWorkshopClient, getWorkshopWebUrl } from "./workshop-client";
function downloadWorkshopSubmission(submission: WorkshopSubmissionDetail) {
const url = URL.createObjectURL(
new Blob([submission.content], { type: submission.type === "customCss" ? "text/css" : "application/json" }),
);
const link = document.createElement("a");
link.href = url;
link.download = workshopExportFilename(submission.title, submission.type);
link.click();
URL.revokeObjectURL(url);
}
interface WorkshopBrowserProps {
type?: WorkshopSubmissionType;
onInstall?(widget: HomarrCustomWidgetV2): Promise<void>;
onUseCss?(css: string): void;
modalStack?: {
modalProps: Pick<ModalProps, "size" | "styles">;
details: WorkshopModalStep;
report: WorkshopModalStep;
};
}
interface WorkshopModalStep {
opened: boolean;
stackId?: string;
open(): void;
close(): void;
}
export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, modalStack }: 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 [internalReportOpened, reportControls] = useDisclosure(false);
const [reportCategory, setReportCategory] = useState<WorkshopReport["category"]>("other");
const [reportExplanation, setReportExplanation] = useState("");
const [loginPending, setLoginPending] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const workshopWebUrl = useMemo(getWorkshopWebUrl, []);
const detailsOpened = modalStack?.details.opened ?? selectedId !== null;
const reportOpened = modalStack?.report.opened ?? internalReportOpened;
const openDetails = (id: string) => {
setSelectedId(id);
modalStack?.details.open();
};
const closeDetails = () => {
modalStack?.details.close();
setSelectedId(null);
};
const openReport = modalStack?.report.open ?? reportControls.open;
const closeReport = modalStack?.report.close ?? reportControls.close;
useEffect(() => {
const unsubscribe = client.subscribeToAuth(setUser);
void client.refreshAuth().then(setUser);
return unsubscribe;
}, [client]);
const list = useWorkshopQuery(client, { page, perPage: 12, type, sort, search: debouncedSearch });
const detail = useWorkshopSubmissionQuery(client, selectedId ?? "");
const reportSummaries = useWorkshopReportSummariesQuery(client, selectedId ?? "");
const detailValidation = useMemo(() => {
if (!detail.data) return null;
return type === "customCss"
? validateWorkshopContent("customCss", detail.data.content)
: validateWorkshopWidget(detail.data.content);
}, [detail.data, type]);
const expectedSchema = type === "customCss" ? WORKSHOP_CSS_SCHEMA : CUSTOM_WIDGET_SCHEMA;
const detailCompatible =
detail.data?.type === type && detailValidation?.success === true && detail.data.widgetSchema === expectedSchema;
const signIn = () => {
setLoginPending(true);
setLoginError(null);
void client
.signInWithGitHub()
.then(setUser)
.catch((cause: unknown) => {
const message = cause instanceof Error ? cause.message : t("signInError");
setLoginError(message);
showErrorNotification({ title: t("signIn"), message });
})
.finally(() => setLoginPending(false));
};
const install = useWorkshopWidgetImportMutation(onInstall);
const useCss = useWorkshopCssImportMutation(onUseCss, () => setSelectedId(null));
const vote = useWorkshopVoteMutation(client);
const report = useWorkshopReportMutation(client);
const submitReport = () =>
report.mutate(
{ submission: selectedId ?? "", category: reportCategory, explanation: reportExplanation },
{
onSuccess: () => {
closeReport();
setReportExplanation("");
showSuccessNotification({ title: t("reportSent"), message: t("reportSentDescription") });
},
},
);
return (
<Stack gap="lg">
<Card withBorder radius="lg" p="lg">
<Group justify="space-between" align="flex-start">
<Group align="flex-start">
<Box
p="sm"
bg="var(--mantine-color-default-hover)"
style={{ borderRadius: "var(--mantine-radius-md)", lineHeight: 0 }}
>
<IconBuildingStore size={24} />
</Box>
<Box>
<Title order={2}>{t("title")}</Title>
<Text c="dimmed" size="sm" maw={640}>
{type === "customCss" ? t("descriptionCss") : t("description")}
</Text>
</Box>
</Group>
<Group gap="xs">
<Button
component="a"
href={workshopWebUrl}
target="_blank"
rel="noopener noreferrer"
size="xs"
variant="default"
leftSection={<IconExternalLink size={14} />}
>
{t("openCommunity")}
</Button>
{user ? (
<>
<Badge variant="light">{user.name}</Badge>
<Button
size="xs"
variant="subtle"
onClick={() => {
client.signOut();
setUser(null);
}}
>
{t("signOut")}
</Button>
</>
) : (
<Button size="xs" loading={loginPending} onClick={signIn}>
{t("signIn")}
</Button>
)}
</Group>
</Group>
</Card>
<Alert color="yellow" icon={<IconAlertTriangle size={18} />}>
{type === "customCss" ? t("securityNoticeCss") : t("securityNotice")}
</Alert>
{loginError && <Alert color="red">{loginError}</Alert>}
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<TextInput
label={type === "customCss" ? t("searchCss") : t("search")}
leftSection={<IconSearch size={16} />}
rightSection={list.isFetching ? <Loader size={16} /> : undefined}
value={search}
onChange={(event) => {
setSearch(event.currentTarget.value);
setPage(1);
}}
/>
<Select
label={t("sort")}
value={sort}
onChange={(value) => {
setSort(value === "newest" ? "newest" : "top");
setPage(1);
}}
data={[
{ value: "top", label: t("sortTop") },
{ value: "newest", label: t("sortNewest") },
]}
allowDeselect={false}
/>
</SimpleGrid>
{list.isLoading ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
<Skeleton h={280} />
<Skeleton h={280} />
<Skeleton h={280} />
</SimpleGrid>
) : list.isError ? (
<Alert color="yellow" icon={<IconAlertTriangle size={18} />} title={t("unavailableTitle")}>
<Stack gap="sm" align="flex-start">
<Text size="sm">{t("unavailable")}</Text>
<Button
size="xs"
variant="light"
leftSection={<IconRefresh size={14} />}
loading={list.isFetching}
onClick={() => void list.refetch()}
>
{t("retry")}
</Button>
</Stack>
</Alert>
) : list.data?.items.length ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
{list.data.items.map((item) => (
<Card key={item.id} withBorder radius="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 || t("communityMember") })}
</Text>
<Text size="sm" c="dimmed">
<IconThumbUp size={13} /> {item.upvotes} · <IconThumbDown size={13} /> {item.downvotes}
</Text>
</Group>
<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}>
{item.description || t("noDescription")}
</Text>
<Button variant="light" onClick={() => openDetails(item.id)}>
{type === "customCss" ? t("inspectCss") : t("install")}
</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
{...modalStack?.modalProps}
opened={detailsOpened}
onClose={closeDetails}
stackId={modalStack?.details.stackId}
title={type === "customCss" ? t("inspectCss") : t("install")}
size={modalStack?.modalProps.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 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}
</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>
)}
{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>
</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>
)}
</Modal>
<Modal
{...modalStack?.modalProps}
opened={reportOpened}
onClose={closeReport}
stackId={modalStack?.report.stackId}
title={t("reportTitle")}
>
<Stack>
<Select
label={t("reportReason")}
value={reportCategory}
onChange={(value) => setReportCategory((value as WorkshopReport["category"]) ?? "other")}
data={(["outdated", "malicious", "spam", "copyright", "inappropriate", "other"] as const).map((value) => ({
value,
label: t(`reportCategory.${value}`),
}))}
allowDeselect={false}
/>
<Textarea
label={t("reportExplanation")}
minRows={4}
value={reportExplanation}
onChange={(event) => setReportExplanation(event.currentTarget.value)}
/>
{report.error && <Alert color="red">{report.error.message}</Alert>}
<Button loading={report.isPending} disabled={reportExplanation.trim().length < 3} onClick={submitReport}>
{t("reportSend")}
</Button>
</Stack>
</Modal>
</Stack>
);
}
function WorkshopCodeViewer({ value, language }: { value: string; language: "json" | "css" }) {
const id = useId();
const t = useScopedI18n("customWidget.editor");
const workshopT = useScopedI18n("workshop");
const messages: CustomWidgetEditorMessages = {
languageJsx: t("language.jsx"),
languageJson: t("language.json"),
undo: t("action.undo"),
redo: t("action.redo"),
components: t("action.components"),
componentSearch: t("componentReference.search"),
componentEmpty: t("componentReference.empty"),
componentCount: (count) => t("componentReference.count", { count }),
insertStarter: t("action.insertStarter"),
format: t("action.format"),
copy: t("action.copy"),
copied: t("action.copied"),
schema: t("action.schema"),
schemaTab: t("reference.schema"),
minimalTab: t("reference.minimal"),
fullTab: t("reference.full"),
errors: (count) => t("status.errors", { count }),
warnings: (count) => t("status.warnings", { count }),
ready: t("status.ready"),
position: (cursor) => t("status.position", cursor),
characters: (count, limit) =>
limit ? t("status.charactersWithLimit", { count, limit }) : t("status.characters", { count }),
diagnosticsTitle: t("diagnostics.title"),
diagnostic: (diagnostic) => diagnostic.value ?? diagnostic.code,
};
let displayValue = value;
if (language === "json") {
try {
displayValue = JSON.stringify(JSON.parse(value) as unknown, null, 2);
} catch {}
}
return (
<ReadOnlyCustomWidgetCode
id={`workshop-source-${id}`}
label={workshopT("sourceCode")}
language={language}
value={displayValue}
messages={messages}
height="360px"
/>
);
}
function CapabilitySummary({ widget }: { widget: HomarrCustomWidgetV2 }) {
const t = useScopedI18n("workshop");
return (
<SimpleGrid cols={{ base: 1, sm: 2 }}>
<Card withBorder p="sm">
<Text fw={600} size="sm" mb="xs">
{t("sources")}
</Text>
<Stack gap={6}>
{Object.entries(widget.sources).map(([sourceId, source]) => (
<Box key={sourceId}>
<Text size="sm" fw={500}>
{source.name}
</Text>
<Group gap={4}>
<Badge size="xs" variant="light">
{new URL(source.baseUrl).origin}
</Badge>
<Badge size="xs" variant="outline">
{source.networkScope}
</Badge>
<Badge size="xs" variant="outline">
{typeof source.auth === "string" ? source.auth : source.auth.type}
</Badge>
</Group>
</Box>
))}
</Stack>
</Card>
<Card withBorder p="sm">
<Text fw={600} size="sm" mb="xs">
{t("requests")}
</Text>
{Object.keys(widget.requests).length === 0 ? (
<Text size="xs" c="dimmed">
{t("noRequests")}
</Text>
) : (
<Stack gap={6}>
{Object.entries(widget.requests).map(([requestId, request]) => (
<Group key={requestId} justify="space-between" wrap="nowrap">
<Text size="xs" ff="monospace" truncate>
{requestId}
</Text>
<Group gap={4} wrap="nowrap">
<Badge size="xs">{request.kind}</Badge>
<Badge size="xs" variant="light">
{request.method}
</Badge>
<Badge size="xs" variant="outline">
{request.permission}
</Badge>
</Group>
</Group>
))}
</Stack>
)}
</Card>
</SimpleGrid>
);
}
@@ -0,0 +1,66 @@
"use client";
import { useId } from "react";
import { ReadOnlyCustomWidgetCode } from "@homarr/custom-widgets/workbench";
import type { CustomWidgetEditorMessages } from "@homarr/custom-widgets/workbench";
import { useScopedI18n } from "@homarr/translation/client";
/** Read-only source view of a Workshop submission, used for both widget JSON and Custom CSS. */
export function WorkshopCodeViewer({
value,
language,
height = "360px",
}: {
value: string;
language: "json" | "css";
height?: string;
}) {
const id = useId();
const t = useScopedI18n("customWidget.editor");
const workshopT = useScopedI18n("workshop");
const messages: CustomWidgetEditorMessages = {
languageJsx: t("language.jsx"),
languageJson: t("language.json"),
undo: t("action.undo"),
redo: t("action.redo"),
components: t("action.components"),
componentSearch: t("componentReference.search"),
componentEmpty: t("componentReference.empty"),
componentCount: (count) => t("componentReference.count", { count }),
insertStarter: t("action.insertStarter"),
format: t("action.format"),
copy: t("action.copy"),
copied: t("action.copied"),
schema: t("action.schema"),
schemaTab: t("reference.schema"),
minimalTab: t("reference.minimal"),
fullTab: t("reference.full"),
errors: (count) => t("status.errors", { count }),
warnings: (count) => t("status.warnings", { count }),
ready: t("status.ready"),
position: (cursor) => t("status.position", cursor),
characters: (count, limit) =>
limit ? t("status.charactersWithLimit", { count, limit }) : t("status.characters", { count }),
diagnosticsTitle: t("diagnostics.title"),
diagnostic: (diagnostic) => diagnostic.value ?? diagnostic.code,
};
let displayValue = value;
if (language === "json") {
try {
displayValue = JSON.stringify(JSON.parse(value) as unknown, null, 2);
} catch {}
}
return (
<ReadOnlyCustomWidgetCode
id={`workshop-source-${id}`}
label={workshopT("sourceCode")}
language={language}
value={displayValue}
messages={messages}
height={height}
/>
);
}
@@ -1,60 +1,144 @@
"use client";
import { Button, Modal, useModalsStack } from "@mantine/core";
import type { ModalProps } from "@mantine/core";
import { IconDownload } from "@tabler/icons-react";
import { useState } from "react";
import { ActionIcon, Button, Group, Loader, Modal, Pagination, Stack, Text, TextInput, Tooltip } from "@mantine/core";
import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
import { IconDownload, IconExternalLink, IconSearch } from "@tabler/icons-react";
import { WorkshopBrowser } from "./workshop-browser";
import { modalSizeSelect, useConfirmModal } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useWorkshopQuery } from "@homarr/workshop/backend";
import type { WorkshopSubmissionSummary } from "@homarr/workshop/schema";
import { validateWorkshopContent } from "@homarr/workshop/schema";
const cssInstallModalProps = {
size: "90%",
styles: {
content: { display: "flex", flexDirection: "column", height: "min(85dvh, 900px)" },
body: { flex: 1, overflowY: "auto" },
},
} satisfies Pick<ModalProps, "size" | "styles">;
import { NoResults } from "~/components/no-results";
import { getWorkshopWebUrl } from "./workshop-client";
import { useWorkshopSession } from "./workshop-session";
import { WorkshopSubmissionGrid } from "./workshop-submission-grid";
import { WorkshopVoteControl } from "./workshop-vote-control";
/**
* Picks a community CSS theme in a single surface: browse, confirm, done.
* The imported rules land in the editor below and are only persisted when the
* board settings themselves are saved, so there is nothing to preview twice.
*/
export function WorkshopCssImportButton({ onImport }: { onImport(css: string): void }) {
const stack = useModalsStack(["workshop", "details", "report"]);
const detailsModal = stack.register("details");
const reportModal = stack.register("report");
const t = useScopedI18n("workshop");
const [opened, controls] = useDisclosure(false);
return (
<>
<Button
type="button"
variant="light"
leftSection={<IconDownload size={16} />}
onClick={() => stack.open("workshop")}
>
Import from Workshop
<Button type="button" variant="light" leftSection={<IconDownload size={16} />} onClick={controls.open}>
{t("importCss")}
</Button>
<Modal.Stack>
<Modal {...stack.register("workshop")} {...cssInstallModalProps} title="Import Custom CSS">
<WorkshopBrowser
type="customCss"
modalStack={{
modalProps: cssInstallModalProps,
details: {
opened: detailsModal.opened,
stackId: detailsModal.stackId,
open: () => stack.open("details"),
close: () => stack.close("details"),
},
report: {
opened: reportModal.opened,
stackId: reportModal.stackId,
open: () => stack.open("report"),
close: () => stack.close("report"),
},
}}
onUseCss={(css) => {
onImport(css);
stack.closeAll();
}}
/>
</Modal>
</Modal.Stack>
<Modal opened={opened} onClose={controls.close} title={t("importCss")} size={modalSizeSelect} radius="md">
<WorkshopCssPicker
onImport={(css) => {
onImport(css);
controls.close();
}}
/>
</Modal>
</>
);
}
function WorkshopCssPicker({ onImport }: { onImport(css: string): void }) {
const t = useScopedI18n("workshop");
const session = useWorkshopSession();
const { openConfirmModal } = useConfirmModal();
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 250);
const [page, setPage] = useState(1);
const [pendingId, setPendingId] = useState<string | null>(null);
const query = useWorkshopQuery(session.client, {
page,
perPage: 8,
type: "customCss",
sort: "top",
search: debouncedSearch,
});
const totalPages = query.data?.totalPages ?? 1;
const useTheme = async (item: WorkshopSubmissionSummary) => {
setPendingId(item.id);
try {
const submission = await session.client.get(item.id);
const validation = validateWorkshopContent("customCss", submission.content);
if (!validation.success) throw new Error(validation.error);
const css = validation.data;
if (typeof css !== "string") throw new Error(t("loadError"));
openConfirmModal({
title: t("importCss"),
children: t("importCssConfirm", { title: item.title }),
onConfirm: () => onImport(css),
});
} catch (cause) {
showErrorNotification({
title: t("importCss"),
message: cause instanceof Error ? cause.message : t("loadError"),
});
} finally {
setPendingId(null);
}
};
return (
<Stack gap="md">
<Text size="sm" c="dimmed">
{t("securityNoticeCss")}
</Text>
<TextInput
aria-label={t("searchCss")}
placeholder={`${t("searchCss")}...`}
leftSection={<IconSearch size={16} />}
rightSection={query.isFetching ? <Loader size={16} /> : undefined}
value={search}
onChange={(event) => {
setSearch(event.currentTarget.value);
setPage(1);
}}
/>
<WorkshopSubmissionGrid
client={session.client}
query={query}
ariaLabel={t("listAriaLabelCss")}
cols={{ base: 1, sm: 2 }}
emptyState={<NoResults icon={IconSearch} title={t("emptyCss")} />}
renderActions={(item) => (
<>
<WorkshopVoteControl
client={session.client}
submissionId={item.id}
score={item.score}
canVote={session.user !== null}
size="sm"
/>
<Group gap={4} wrap="nowrap">
<Tooltip label={t("openCommunity")}>
<ActionIcon
component="a"
href={getWorkshopWebUrl(item.id)}
target="_blank"
rel="noopener noreferrer"
variant="subtle"
color="gray"
size="lg"
aria-label={t("openOnWorkshop", { title: item.title })}
>
<IconExternalLink size={16} stroke={1.5} />
</ActionIcon>
</Tooltip>
<Button size="sm" loading={pendingId === item.id} onClick={() => void useTheme(item)}>
{t("useCss")}
</Button>
</Group>
</>
)}
/>
{totalPages > 1 && <Pagination total={totalPages} value={page} onChange={setPage} mx="auto" />}
</Stack>
);
}
@@ -1,93 +0,0 @@
"use client";
import type { ReactNode } from "react";
import { useState } from "react";
import { Button, Modal, useModalsStack } from "@mantine/core";
import type { ModalProps } from "@mantine/core";
import { IconBuildingStore } from "@tabler/icons-react";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { useScopedI18n } from "@homarr/translation/client";
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
import { WorkshopBrowser } from "./workshop-browser";
interface WorkshopInstallButtonProps {
children?: ReactNode;
fullWidth?: boolean;
}
const installFlowModalProps = {
size: "90%",
styles: {
content: { display: "flex", flexDirection: "column", height: "min(85dvh, 900px)" },
body: { flex: 1, overflowY: "auto" },
},
} satisfies Pick<ModalProps, "size" | "styles">;
export function WorkshopInstallButton({ children, fullWidth }: WorkshopInstallButtonProps = {}) {
const t = useScopedI18n("workshop");
const stack = useModalsStack(["workshop", "details", "report", "review"]);
const [pendingWidget, setPendingWidget] = useState<HomarrCustomWidgetV2 | null>(null);
const detailsModal = stack.register("details");
const reportModal = stack.register("report");
const closeReview = () => {
stack.close("review");
setPendingWidget(null);
};
return (
<>
<Button
variant="default"
leftSection={<IconBuildingStore size={16} />}
onClick={() => stack.open("workshop")}
fullWidth={fullWidth}
>
{children ?? t("importFromWorkshop")}
</Button>
<Modal.Stack>
<Modal {...stack.register("workshop")} {...installFlowModalProps} title={t("installDialog")}>
<WorkshopBrowser
modalStack={{
modalProps: installFlowModalProps,
details: {
opened: detailsModal.opened,
stackId: detailsModal.stackId,
open: () => stack.open("details"),
close: () => stack.close("details"),
},
report: {
opened: reportModal.opened,
stackId: reportModal.stackId,
open: () => stack.open("report"),
close: () => stack.close("report"),
},
}}
onInstall={async (widget) => {
setPendingWidget(widget);
stack.open("review");
}}
/>
</Modal>
<CustomWidgetImportDialog
{...stack.register("review")}
widget={pendingWidget}
modalProps={installFlowModalProps}
labels={{
title: t("confirmInstallTitle"),
description: t("confirmInstallDescription"),
cancel: t("backToWidget"),
confirm: t("install"),
}}
onClose={closeReview}
onImported={() => {
stack.closeAll();
setPendingWidget(null);
}}
/>
</Modal.Stack>
</>
);
}
@@ -5,6 +5,13 @@ import { afterEach, describe, expect, it } from "vitest";
import { getWorkshopAssistantProviderUrl, getWorkshopWebUrl } from "./workshop-client";
const read = (path: string) => readFileSync(`${process.cwd()}/${path}`, "utf8");
const detailPage = "apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/[id]/_workshop-detail.tsx";
const browsePage = "apps/nextjs/src/app/[locale]/manage/custom-widgets/workshop/_workshop-browse.tsx";
const publishPage = "apps/nextjs/src/app/[locale]/manage/custom-widgets/publish/[id]/_workshop-publish-form.tsx";
const cssButton = "apps/nextjs/src/components/workshop/workshop-css-import-button.tsx";
describe("Workshop widget install flow", () => {
afterEach(() => {
document.head.innerHTML = "";
@@ -32,32 +39,124 @@ describe("Workshop widget install flow", () => {
expect(getWorkshopAssistantProviderUrl()).toBe("https://api.preview.example/api/ai/v1");
});
it("uses one registered modal stack for browsing, details, reports, and confirmation", () => {
const installButton = readFileSync(
`${process.cwd()}/apps/nextjs/src/components/workshop/workshop-install-button.tsx`,
"utf8",
);
const browser = readFileSync(`${process.cwd()}/apps/nextjs/src/components/workshop/workshop-browser.tsx`, "utf8");
it("installs from a page with a sticky footer instead of stacked modals", () => {
const detail = read(detailPage);
expect(installButton).toContain('useModalsStack(["workshop", "details", "report", "review"])');
expect(installButton).toContain("<Modal.Stack>");
expect(installButton).toContain('height: "min(85dvh, 900px)"');
expect(installButton).not.toContain("getDefaultZIndex");
expect(browser).toContain("ReadOnlyCustomWidgetCode");
expect(browser).not.toContain("{item.widgetSchema}");
expect(detail).toContain("ManageStickyFooter");
expect(detail).toContain("useCustomWidgetImport");
// Review and install happen on one surface, so neither Modal.Stack nor the review dialog is involved.
expect(detail).not.toContain("Modal.Stack");
expect(detail).not.toContain("useModalsStack");
expect(detail).not.toContain("CustomWidgetImportDialog");
});
it("uses a two-step modal stack for Custom CSS", () => {
const cssButton = readFileSync(
`${process.cwd()}/apps/nextjs/src/components/workshop/workshop-css-import-button.tsx`,
"utf8",
);
const browser = readFileSync(`${process.cwd()}/apps/nextjs/src/components/workshop/workshop-browser.tsx`, "utf8");
it("browses the Workshop as a card grid with per-item Workshop links", () => {
const browse = read(browsePage);
expect(cssButton).toContain('useModalsStack(["workshop", "details", "report"])');
expect(cssButton).toContain("<Modal.Stack>");
expect(cssButton).toContain('height: "min(85dvh, 900px)"');
expect(browser).not.toContain("cssAwaitingConfirmation");
expect(browser).toContain("useWorkshopReportSummariesQuery");
expect(browse).toContain("WorkshopSubmissionGrid");
expect(browse).toContain("SearchInput");
expect(browse).toContain("TablePagination");
expect(browse).toContain("CustomWidgetTabs");
expect(browse).toContain("getWorkshopWebUrl(item.id)");
expect(browse).toContain("WorkshopVoteControl");
expect(browse).not.toContain("Modal");
});
it("renders the grid with real cards and screenshot previews", () => {
const grid = read("apps/nextjs/src/components/workshop/workshop-submission-grid.tsx");
const card = read("apps/nextjs/src/components/workshop/workshop-submission-card.tsx");
expect(grid).toContain("SimpleGrid");
expect(card).toContain("Card.Section");
expect(card).toContain("PREVIEW_HEIGHT");
});
it("shows the viewer's own vote as a filled control", () => {
const vote = read("apps/nextjs/src/components/workshop/workshop-vote-control.tsx");
expect(vote).toContain("useWorkshopUserVotesQuery");
expect(vote).toContain("IconArrowBigUpFilled");
expect(vote).toContain("IconArrowBigDownFilled");
expect(vote).toContain('color={upvoted ? "red" : "gray"}');
expect(vote).toContain("aria-pressed={upvoted}");
});
it("previews screenshots large with a zoom lightbox", () => {
const screenshots = read("apps/nextjs/src/components/workshop/workshop-screenshots.tsx");
const detail = read(detailPage);
expect(screenshots).toContain("1200x800");
expect(screenshots).toContain("zoomScreenshot");
expect(detail).toContain("WorkshopScreenshots");
});
it("publishes from a page with the same sticky footer", () => {
const publish = read(publishPage);
expect(publish).toContain("ManageStickyFooter");
expect(publish).not.toContain("useModalsStack");
expect(publish).not.toMatch(/<Modal\b/u);
});
it("imports Custom CSS from a single modal", () => {
const css = read(cssButton);
expect(css).toContain("WorkshopSubmissionGrid");
expect(css).toContain("useConfirmModal");
expect(css).not.toContain("useModalsStack");
expect(css).not.toContain("Modal.Stack");
});
it("cannot import the same widget twice and returns to the installed list", () => {
const importer = read("apps/nextjs/src/components/custom-widgets/use-custom-widget-import.ts");
const detail = read(detailPage);
// The mutation creates a new record per call, so a second click must be impossible.
expect(importer).toContain("if (!configuredWidget || pending || succeeded) return;");
expect(importer).toContain("setSucceeded(true)");
expect(detail).toContain("router.push(installedHref)");
expect(detail).toContain("disabled={!compatible || !importer.ready || importer.succeeded}");
// The heavy edit form is no longer the post-install destination.
expect(detail).not.toContain("/manage/custom-widgets/edit/");
});
it("keeps typed credentials across a background refetch of the submission", () => {
const importer = read("apps/nextjs/src/components/custom-widgets/use-custom-widget-import.ts");
const detail = read(detailPage);
// Voting invalidates the detail query. If the setup reset keyed off the widget
// object identity, the refetch would silently clear part-typed API keys.
expect(importer).toContain("const sourcesKey = JSON.stringify(widget?.sources ?? {})");
expect(importer).toContain("[sourcesKey]");
expect(importer).not.toContain("getCustomWidgetSourceSetups(widget?.sources ?? {}), [widget])");
expect(detail).toContain("const content = detail.data?.content;");
expect(detail).toContain("[content]");
});
it("keeps the sign-in hint reachable when voting is unavailable", () => {
const vote = read("apps/nextjs/src/components/workshop/workshop-vote-control.tsx");
// Mantine tooltips do not fire on disabled controls, so the hint hangs off the wrapper.
expect(vote).toContain('canVote ? controls : <Tooltip label={t("signInHint")}>{controls}</Tooltip>');
});
it("cannot publish the same widget twice", () => {
const publish = read(publishPage);
expect(publish).toContain("if (createSubmission.isPending) return;");
expect(publish).toContain("createSubmission.isPending ||");
});
it("no longer ships the stacked-modal install components", () => {
for (const removed of [
"apps/nextjs/src/components/workshop/workshop-browser.tsx",
"apps/nextjs/src/components/workshop/workshop-install-button.tsx",
"apps/nextjs/src/components/workshop/workshop-installer.tsx",
"apps/nextjs/src/components/workshop/workshop-publish-modal.tsx",
"apps/nextjs/src/components/workshop/workshop-submission-list.tsx",
"apps/nextjs/src/components/workshop/workshop-submission-item.tsx",
]) {
expect(() => read(removed)).toThrow();
}
});
});
@@ -1,36 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useDisclosure } from "@mantine/hooks";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import { CustomWidgetImportDialog } from "~/components/custom-widgets/custom-widget-import-dialog";
import { WorkshopBrowser } from "./workshop-browser";
export function WorkshopInstaller() {
const router = useRouter();
const [pendingWidget, setPendingWidget] = useState<HomarrCustomWidgetV2 | null>(null);
const [reviewOpened, reviewControls] = useDisclosure(false);
const install = async (widget: HomarrCustomWidgetV2) => {
setPendingWidget(widget);
reviewControls.open();
};
return (
<>
<WorkshopBrowser onInstall={install} />
<CustomWidgetImportDialog
opened={reviewOpened}
widget={pendingWidget}
onClose={() => {
reviewControls.close();
setPendingWidget(null);
}}
onImported={(result) => router.push(`/manage/custom-widgets/edit/${result.id}`)}
/>
</>
);
}
@@ -1,275 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Box,
Button,
Checkbox,
Divider,
FileInput,
Group,
Modal,
Paper,
Stack,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
} from "@mantine/core";
import { IconBuildingStore, IconCheck, IconExternalLink, IconPhoto, IconShieldCheck } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { showErrorNotification } from "@homarr/notifications";
import { useWorkshopCreateMutation } from "@homarr/workshop/backend";
import { workshopScreenshotsSchema } from "@homarr/workshop/schema";
import type { WorkshopUser } from "@homarr/workshop/schema";
import { useScopedI18n } from "@homarr/translation/client";
import { createWorkshopClient, getWorkshopWebUrl } from "./workshop-client";
import {
getPrivateWorkshopSourceNames,
publishWorkshopDefinition,
serializeWorkshopDefinition,
} from "./workshop-publish-definition";
export function WorkshopPublishModal({
opened,
onClose,
widget,
}: {
opened: boolean;
onClose(): void;
widget: { id: string; name: string };
}) {
const t = useScopedI18n("workshop");
const client = useMemo(createWorkshopClient, []);
const [user, setUser] = useState<WorkshopUser | null>(null);
const [title, setTitle] = useState(widget.name);
const [description, setDescription] = useState("");
const [screenshots, setScreenshots] = useState<File[]>([]);
const createSubmission = useWorkshopCreateMutation(client);
const [error, setError] = useState<string | null>(null);
const [publishedSubmissionId, setPublishedSubmissionId] = useState<string | null>(null);
const [sourceUrlsReviewed, setSourceUrlsReviewed] = useState(false);
const definition = clientApi.customWidget.export.useQuery({ id: widget.id }, { enabled: opened });
const privateSourceNames = getPrivateWorkshopSourceNames(definition.data);
const definitionFingerprint = definition.data ? serializeWorkshopDefinition(definition.data) : null;
useEffect(() => {
const unsubscribe = client.subscribeToAuth(setUser);
void client.refreshAuth().then(setUser);
return unsubscribe;
}, [client]);
useEffect(() => {
if (!opened) return;
setTitle(widget.name);
setDescription("");
setScreenshots([]);
setError(null);
setPublishedSubmissionId(null);
setSourceUrlsReviewed(false);
}, [opened, widget.name]);
useEffect(() => setSourceUrlsReviewed(false), [definitionFingerprint]);
const publish = async () => {
setError(null);
try {
if (!definition.data) throw new Error(t("publish.error"));
if (!workshopScreenshotsSchema.safeParse(screenshots).success) {
throw new Error(t("publish.invalidScreenshot"));
}
const inspectedDefinition = definition.data;
let submissionId: string | null = null;
const result = await publishWorkshopDefinition({
inspectedDefinition,
refetchDefinition: async () => (await definition.refetch()).data,
publish: async (content) => {
const submission = await createSubmission.mutateAsync({
input: {
type: "customWidget",
title,
description,
content,
},
screenshots,
});
submissionId = submission.id;
},
});
if (result === "unavailable") throw new Error(t("publish.error"));
if (result === "changed") {
setSourceUrlsReviewed(false);
setError(t("publish.definitionChanged"));
return;
}
if (!submissionId) throw new Error(t("publish.error"));
setPublishedSubmissionId(submissionId);
} catch (cause) {
setError(cause instanceof Error ? cause.message : t("publish.error"));
}
};
return (
<Modal opened={opened} onClose={onClose} title={t("publish.title")} size="lg" radius="lg">
<Stack gap="lg">
{publishedSubmissionId ? (
<Stack align="center" gap="lg" py="md">
<ThemeIcon size={64} radius="xl" color="green" variant="light">
<IconCheck size={32} stroke={2} />
</ThemeIcon>
<Box ta="center">
<Title order={3}>{t("publish.successTitle")}</Title>
<Text c="dimmed" size="sm" maw={480} mt={6}>
{t("publish.success")}
</Text>
</Box>
<Paper withBorder radius="md" p="md" w="100%">
<Group wrap="nowrap" align="flex-start">
<ThemeIcon variant="light" color="blue" radius="md" size="lg">
<IconBuildingStore size={20} />
</ThemeIcon>
<Box>
<Text fw={600} size="sm">
{t("publish.manageTitle")}
</Text>
<Text c="dimmed" size="sm" mt={2}>
{t("publish.manageDescription")}
</Text>
</Box>
</Group>
</Paper>
<Group justify="center">
<Button variant="default" onClick={onClose}>
{t("publish.done")}
</Button>
<Button
component="a"
href={getWorkshopWebUrl(publishedSubmissionId)}
target="_blank"
rel="noopener noreferrer"
rightSection={<IconExternalLink size={16} />}
>
{t("publish.viewSubmission")}
</Button>
</Group>
</Stack>
) : (
<>
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-default-hover)">
<Group wrap="nowrap" align="flex-start">
<ThemeIcon variant="light" radius="md" size="lg">
<IconBuildingStore size={20} />
</ThemeIcon>
<Box>
<Text fw={600}>{t("publish.introTitle")}</Text>
<Text c="dimmed" size="sm" mt={2}>
{t("publish.introDescription")}
</Text>
</Box>
</Group>
</Paper>
{!user && (
<Alert color="blue">
<Group justify="space-between">
<span>{t("publish.signInHint")}</span>
<Button
size="xs"
onClick={() =>
void client
.signInWithGitHub()
.then(setUser)
.catch((cause: Error) => {
setError(cause.message);
showErrorNotification({ title: t("signIn"), message: cause.message });
})
}
>
{t("signIn")}
</Button>
</Group>
</Alert>
)}
<Paper withBorder radius="md" p="md">
<Stack gap="md">
<Box>
<Text fw={600} size="sm">
{t("publish.listingDetails")}
</Text>
<Text c="dimmed" size="xs" mt={2}>
{t("publish.listingDetailsDescription")}
</Text>
</Box>
<TextInput
label={t("publish.titleField")}
value={title}
onChange={(event) => setTitle(event.currentTarget.value)}
required
/>
<Textarea
label={t("publish.descriptionField")}
value={description}
onChange={(event) => setDescription(event.currentTarget.value)}
minRows={4}
autosize
maxRows={7}
/>
<FileInput
label={t("publish.screenshots")}
description={t("publish.screenshotsDescription")}
placeholder={t("publish.screenshotsPlaceholder")}
leftSection={<IconPhoto size={16} />}
accept="image/png,image/jpeg,image/webp"
multiple
value={screenshots}
onChange={setScreenshots}
clearable
/>
</Stack>
</Paper>
{privateSourceNames.length > 0 && (
<Alert color="yellow" icon={<IconShieldCheck size={18} />} title={t("publish.reviewSourcesTitle")}>
{t("publish.privateSourceWarning", {
sources: privateSourceNames.join(", "),
})}
<Checkbox
mt="sm"
checked={sourceUrlsReviewed}
onChange={(event) => setSourceUrlsReviewed(event.currentTarget.checked)}
label={t("publish.privateSourceConfirmation")}
/>
</Alert>
)}
{definition.isError && <Alert color="red">{t("publish.error")}</Alert>}
{error && <Alert color="red">{error}</Alert>}
<Divider />
<Group justify="space-between" align="center">
<Text c="dimmed" size="xs" maw={360}>
{t("publish.publicationNote")}
</Text>
<Group gap="sm">
<Button variant="default" onClick={onClose}>
{t("publish.cancel")}
</Button>
<Button
loading={createSubmission.isPending || definition.isFetching}
disabled={
!user ||
!definition.data ||
definition.isError ||
title.trim().length < 3 ||
(privateSourceNames.length > 0 && !sourceUrlsReviewed)
}
onClick={() => void publish()}
>
{t("publish.action")}
</Button>
</Group>
</Group>
</>
)}
</Stack>
</Modal>
);
}
@@ -0,0 +1,79 @@
"use client";
import { useState } from "react";
import { Alert, Button, Group, Modal, Select, Stack, Textarea } from "@mantine/core";
import { showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import type { WorkshopBackend } from "@homarr/workshop/backend";
import { useWorkshopReportMutation } from "@homarr/workshop/backend";
import type { WorkshopReport } from "@homarr/workshop/schema";
const categories = ["outdated", "malicious", "spam", "copyright", "inappropriate", "other"] as const;
interface WorkshopReportModalProps {
client: WorkshopBackend;
submissionId: string;
opened: boolean;
onClose(): void;
}
export function WorkshopReportModal({ client, submissionId, opened, onClose }: WorkshopReportModalProps) {
const t = useScopedI18n("workshop");
const [category, setCategory] = useState<WorkshopReport["category"]>("other");
const [explanation, setExplanation] = useState("");
const report = useWorkshopReportMutation(client);
const close = () => {
setExplanation("");
report.reset();
onClose();
};
const submit = () =>
report.mutate(
{ submission: submissionId, category, explanation },
{
onSuccess: () => {
showSuccessNotification({
title: t("reportSent"),
message: t("reportSentDescription"),
});
close();
},
},
);
return (
<Modal opened={opened} onClose={close} title={t("reportTitle")} centered radius="md">
<Stack>
<Select
label={t("reportReason")}
value={category}
onChange={(value) => setCategory((value as WorkshopReport["category"] | null) ?? "other")}
data={categories.map((value) => ({
value,
label: t(`reportCategory.${value}`),
}))}
allowDeselect={false}
/>
<Textarea
label={t("reportExplanation")}
minRows={4}
autosize
value={explanation}
onChange={(event) => setExplanation(event.currentTarget.value)}
/>
{report.error && <Alert color="red">{report.error.message}</Alert>}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={close}>
{t("cancel")}
</Button>
<Button color="red" loading={report.isPending} disabled={explanation.trim().length < 3} onClick={submit}>
{t("reportSend")}
</Button>
</Group>
</Stack>
</Modal>
);
}
@@ -0,0 +1,141 @@
"use client";
import { useState } from "react";
import { ActionIcon, Box, Group, Image, Modal, Paper, Tooltip, UnstyledButton } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconChevronLeft, IconChevronRight, IconZoomIn } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import type { WorkshopBackend } from "@homarr/workshop/backend";
interface WorkshopScreenshotsProps {
client: WorkshopBackend;
submissionId: string;
title: string;
screenshots: string[];
}
/**
* A large preview of the submission's screenshots with a thumbnail strip and a
* click-to-zoom lightbox, so people can actually see what they are installing.
*/
export function WorkshopScreenshots({ client, submissionId, title, screenshots }: WorkshopScreenshotsProps) {
const t = useScopedI18n("workshop");
const [index, setIndex] = useState(0);
const [zoomOpened, zoomControls] = useDisclosure(false);
if (screenshots.length === 0) return null;
const active = screenshots[Math.min(index, screenshots.length - 1)];
if (!active) return null;
const alt = (position: number) => t("screenshotAlt", { title, count: position + 1 });
const step = (delta: number) => setIndex((current) => (current + delta + screenshots.length) % screenshots.length);
return (
<>
<Paper withBorder radius="md" p={0} style={{ overflow: "hidden", position: "relative" }}>
<UnstyledButton onClick={zoomControls.open} w="100%" aria-label={t("zoomScreenshot")}>
<Image
src={client.fileUrl(submissionId, active, "1200x800")}
alt={alt(index)}
fit="contain"
h={{ base: 220, sm: 380 }}
bg="var(--mantine-color-default-hover)"
/>
</UnstyledButton>
<Tooltip label={t("zoomScreenshot")}>
<ActionIcon
variant="default"
size="lg"
radius="md"
pos="absolute"
top={8}
right={8}
aria-label={t("zoomScreenshot")}
onClick={zoomControls.open}
>
<IconZoomIn size={18} stroke={1.5} />
</ActionIcon>
</Tooltip>
{screenshots.length > 1 && (
<>
<ActionIcon
variant="default"
size="lg"
radius="xl"
pos="absolute"
left={8}
top="50%"
style={{ transform: "translateY(-50%)" }}
aria-label={t("previousScreenshot")}
onClick={() => step(-1)}
>
<IconChevronLeft size={18} />
</ActionIcon>
<ActionIcon
variant="default"
size="lg"
radius="xl"
pos="absolute"
right={8}
top="50%"
style={{ transform: "translateY(-50%)" }}
aria-label={t("nextScreenshot")}
onClick={() => step(1)}
>
<IconChevronRight size={18} />
</ActionIcon>
</>
)}
</Paper>
{screenshots.length > 1 && (
<Group gap="xs" wrap="wrap">
{screenshots.map((file, position) => (
<UnstyledButton
key={file}
onClick={() => setIndex(position)}
aria-label={alt(position)}
aria-pressed={position === index}
>
<Box
w={96}
h={64}
style={{
overflow: "hidden",
borderRadius: "var(--mantine-radius-sm)",
outline:
position === index
? "2px solid var(--mantine-primary-color-filled)"
: "1px solid var(--mantine-color-default-border)",
opacity: position === index ? 1 : 0.65,
}}
>
<Image src={client.fileUrl(submissionId, file, "192x128")} alt="" h={64} w={96} fit="cover" />
</Box>
</UnstyledButton>
))}
</Group>
)}
<Modal
opened={zoomOpened}
onClose={zoomControls.close}
size="auto"
centered
padding="xs"
title={alt(index)}
styles={{ body: { display: "flex", justifyContent: "center" } }}
>
<Image
src={client.fileUrl(submissionId, active)}
alt={alt(index)}
fit="contain"
mah="80dvh"
style={{ maxWidth: "min(90vw, 1400px)" }}
/>
</Modal>
</>
);
}
@@ -0,0 +1,79 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Button, Group, Text } from "@mantine/core";
import { IconBrandGithub } from "@tabler/icons-react";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import type { WorkshopUser } from "@homarr/workshop/schema";
import { createWorkshopClient } from "./workshop-client";
/**
* Shared Workshop identity. Signing in is only needed to vote or report,
* never to install, so every surface can render this without gating its content.
*/
export function useWorkshopSession() {
const t = useScopedI18n("workshop");
const client = useMemo(createWorkshopClient, []);
const [user, setUser] = useState<WorkshopUser | null>(null);
const [pending, setPending] = useState(false);
useEffect(() => {
const unsubscribe = client.subscribeToAuth(setUser);
void client.refreshAuth().then(setUser);
return unsubscribe;
}, [client]);
const signIn = () => {
setPending(true);
void client
.signInWithGitHub()
.then(setUser)
.catch((cause: unknown) => {
showErrorNotification({
title: t("signIn"),
message: cause instanceof Error ? cause.message : t("signInError"),
});
})
.finally(() => setPending(false));
};
const signOut = () => {
client.signOut();
setUser(null);
};
return { client, user, pending, signIn, signOut };
}
export type WorkshopSession = ReturnType<typeof useWorkshopSession>;
export function WorkshopAccountButton({ session }: { session: WorkshopSession }) {
const t = useScopedI18n("workshop");
if (!session.user) {
return (
<Button
variant="default"
leftSection={<IconBrandGithub size={16} />}
loading={session.pending}
onClick={session.signIn}
>
{t("signIn")}
</Button>
);
}
return (
<Group gap="xs" wrap="nowrap">
<Text size="sm" c="dimmed" lineClamp={1}>
{session.user.name}
</Text>
<Button variant="subtle" color="gray" size="compact-sm" onClick={session.signOut}>
{t("signOut")}
</Button>
</Group>
);
}
@@ -0,0 +1,84 @@
"use client";
import type { ReactNode } from "react";
import { Badge, Box, Card, Group, Image, Stack, Text, Tooltip } from "@mantine/core";
import { IconAlertTriangle, IconFlag, IconPhoto } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import type { WorkshopBackend } from "@homarr/workshop/backend";
import type { WorkshopSubmissionSummary } from "@homarr/workshop/schema";
/** Aspect-ratio-preserving preview strip. Large enough to actually judge a widget by. */
const PREVIEW_HEIGHT = 168;
interface WorkshopSubmissionCardProps {
client: WorkshopBackend;
item: WorkshopSubmissionSummary;
/** Rendered in the card footer, e.g. score, external link and the install button. */
actions: ReactNode;
}
export function WorkshopSubmissionCard({ client, item, actions }: WorkshopSubmissionCardProps) {
const t = useScopedI18n("workshop");
const screenshot = item.screenshots[0];
return (
<Card component="li" withBorder radius="md" padding="md" h="100%">
<Card.Section>
{screenshot ? (
<Image
src={client.fileUrl(item.id, screenshot, "600x400")}
h={PREVIEW_HEIGHT}
fit="cover"
alt={t("screenshotAlt", { title: item.title, count: 1 })}
/>
) : (
<Box
h={PREVIEW_HEIGHT}
bg="var(--mantine-color-default-hover)"
style={{ display: "grid", placeItems: "center" }}
>
<Stack gap={4} align="center">
<IconPhoto size={28} stroke={1.5} opacity={0.5} />
<Text size="xs" c="dimmed" fw={600}>
{item.type === "customCss" ? "CSS" : "JSX"}
</Text>
</Stack>
</Box>
)}
</Card.Section>
<Stack gap="xs" mt="md" style={{ flex: 1 }}>
<Group gap={6} wrap="wrap">
{item.outdated && (
<Badge size="sm" variant="light" color="yellow" leftSection={<IconAlertTriangle size={11} />}>
{t("outdated")}
</Badge>
)}
{item.reportCount > 0 && (
<Tooltip label={t("reportWarning", { count: item.reportCount })} multiline maw={320}>
<Badge size="sm" variant="light" color="red" leftSection={<IconFlag size={11} />}>
{t("reportCount", { count: item.reportCount })}
</Badge>
</Tooltip>
)}
</Group>
<Box>
<Text fw={600} lineClamp={1}>
{item.title}
</Text>
<Text size="xs" c="dimmed">
{t("author", { name: item.authorName || t("communityMember") })}
</Text>
</Box>
<Text size="sm" c="dimmed" lineClamp={2} style={{ flex: 1 }}>
{item.description || t("noDescription")}
</Text>
</Stack>
<Group justify="space-between" align="center" gap="xs" wrap="nowrap" mt="md">
{actions}
</Group>
</Card>
);
}
@@ -0,0 +1,84 @@
"use client";
import type { ReactNode } from "react";
import { Alert, Button, SimpleGrid, Skeleton, Stack, Text } from "@mantine/core";
import type { StyleProp } from "@mantine/core";
import { IconAlertTriangle, IconRefresh } from "@tabler/icons-react";
import type { UseQueryResult } from "@tanstack/react-query";
import { useScopedI18n } from "@homarr/translation/client";
import type { WorkshopBackend, WorkshopPage } from "@homarr/workshop/backend";
import type { WorkshopSubmissionSummary } from "@homarr/workshop/schema";
import { WorkshopSubmissionCard } from "./workshop-submission-card";
interface WorkshopSubmissionGridProps {
client: WorkshopBackend;
query: UseQueryResult<WorkshopPage<WorkshopSubmissionSummary>>;
ariaLabel: string;
emptyState: ReactNode;
cols?: StyleProp<number>;
renderActions(item: WorkshopSubmissionSummary): ReactNode;
}
/**
* Loading, failure, empty and loaded states for a page of Workshop submissions.
* Shared so the widget browser and the Custom CSS picker behave identically.
*/
export function WorkshopSubmissionGrid({
client,
query,
ariaLabel,
emptyState,
cols = { base: 1, sm: 2, lg: 3 },
renderActions,
}: WorkshopSubmissionGridProps) {
const t = useScopedI18n("workshop");
if (query.isLoading) {
return (
<SimpleGrid cols={cols} spacing="md">
{[0, 1, 2, 3, 4, 5].map((index) => (
<Skeleton key={index} h={320} radius="md" />
))}
</SimpleGrid>
);
}
if (query.isError) {
return (
<Alert color="yellow" icon={<IconAlertTriangle size={18} />} title={t("unavailableTitle")}>
<Stack gap="sm" align="flex-start">
<Text size="sm">{t("unavailable")}</Text>
<Button
size="xs"
variant="light"
leftSection={<IconRefresh size={14} />}
loading={query.isFetching}
onClick={() => void query.refetch()}
>
{t("retry")}
</Button>
</Stack>
</Alert>
);
}
if (!query.data?.items.length) return <>{emptyState}</>;
return (
<SimpleGrid
component="ul"
aria-label={ariaLabel}
cols={cols}
spacing="md"
m={0}
p={0}
style={{ listStyle: "none" }}
>
{query.data.items.map((item) => (
<WorkshopSubmissionCard key={item.id} client={client} item={item} actions={renderActions(item)} />
))}
</SimpleGrid>
);
}
@@ -0,0 +1,90 @@
"use client";
import { ActionIcon, Group, Text, Tooltip } from "@mantine/core";
import { IconArrowBigDown, IconArrowBigDownFilled, IconArrowBigUp, IconArrowBigUpFilled } from "@tabler/icons-react";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import type { WorkshopBackend } from "@homarr/workshop/backend";
import { useWorkshopUserVotesQuery, useWorkshopVoteMutation } from "@homarr/workshop/backend";
interface WorkshopVoteControlProps {
client: WorkshopBackend;
submissionId: string;
score: number;
/** Voting requires a Workshop account; installing does not. */
canVote: boolean;
size?: "sm" | "md";
}
/**
* Upvote / net score / downvote. The user's own vote is filled and coloured,
* and clicking it again removes the vote.
*/
export function WorkshopVoteControl({ client, submissionId, score, canVote, size = "md" }: WorkshopVoteControlProps) {
const t = useScopedI18n("workshop");
const vote = useWorkshopVoteMutation(client);
const userVotes = useWorkshopUserVotesQuery(client);
const ownVote = userVotes.data?.find((entry) => entry.submission === submissionId)?.value ?? null;
const castVote = (value: 1 | -1) =>
vote.mutate(
{ submission: submissionId, value },
{ onError: (error) => showErrorNotification({ title: t("voteError"), message: error.message }) },
);
const iconSize = size === "sm" ? 16 : 18;
const controlSize = size === "sm" ? "sm" : "md";
const upvoted = ownVote === 1;
const downvoted = ownVote === -1;
const controls = (
<Group gap={2} wrap="nowrap">
<MaybeTooltip label={canVote ? t("upvote") : undefined}>
<ActionIcon
variant={upvoted ? "light" : "subtle"}
color={upvoted ? "red" : "gray"}
size={controlSize}
aria-label={t("upvote")}
aria-pressed={upvoted}
loading={vote.isPending && vote.variables?.value === 1}
disabled={!canVote || vote.isPending}
onClick={() => castVote(1)}
>
{upvoted ? <IconArrowBigUpFilled size={iconSize} /> : <IconArrowBigUp size={iconSize} stroke={1.5} />}
</ActionIcon>
</MaybeTooltip>
<Text
size="sm"
fw={600}
ta="center"
miw={24}
c={upvoted ? "red" : downvoted ? "blue" : undefined}
aria-label={t("score", { count: score })}
>
{score}
</Text>
<MaybeTooltip label={canVote ? t("downvote") : undefined}>
<ActionIcon
variant={downvoted ? "light" : "subtle"}
color={downvoted ? "blue" : "gray"}
size={controlSize}
aria-label={t("downvote")}
aria-pressed={downvoted}
loading={vote.isPending && vote.variables?.value === -1}
disabled={!canVote || vote.isPending}
onClick={() => castVote(-1)}
>
{downvoted ? <IconArrowBigDownFilled size={iconSize} /> : <IconArrowBigDown size={iconSize} stroke={1.5} />}
</ActionIcon>
</MaybeTooltip>
</Group>
);
// Tooltips never fire on disabled controls, so when voting is unavailable the
// hint has to hang off the enabled wrapper instead of the buttons themselves.
return canVote ? controls : <Tooltip label={t("signInHint")}>{controls}</Tooltip>;
}
const MaybeTooltip = ({ label, children }: { label: string | undefined; children: React.ReactElement }) =>
label ? <Tooltip label={label}>{children}</Tooltip> : children;
@@ -1,4 +1,5 @@
import { TRPCError } from "@trpc/server";
import { parse as parseSuperJson } from "superjson";
import { z } from "zod/v4";
import { encryptSecret } from "@homarr/common/server";
@@ -26,6 +27,32 @@ export const transferProcedures = {
return parseStoredCustomWidgetDefinition(definition);
}),
exportLegacy: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const definition = await ctx.db.query.legacyCustomWidgetDefinitions.findFirst({
where: eq(legacyCustomWidgetDefinitions.id, input.id),
with: { secrets: true },
});
if (!definition) throw new TRPCError({ code: "NOT_FOUND", message: "Legacy custom widget not found" });
return {
$schema: "homarr-custom-widget-v1" as const,
name: definition.name,
description: definition.description,
iconUrl: definition.iconUrl,
url: definition.url,
authType: definition.authType,
headerName: definition.headerName,
method: definition.method,
requestBody: definition.requestBody,
displayType: definition.displayType,
displayConfig: parseLegacyDisplayConfig(definition.displayConfig),
configuredSecretKinds: definition.secrets.map(({ kind }) => kind),
};
}),
import: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ widget: customWidgetImportSchema, secrets: customWidgetSecretsInputSchema.default([]) }))
@@ -117,6 +144,14 @@ export const transferProcedures = {
}),
};
function parseLegacyDisplayConfig(value: string): unknown {
try {
return parseSuperJson(value);
} catch {
return value;
}
}
function canPreserveLegacySecrets(
legacy: typeof legacyCustomWidgetDefinitions.$inferSelect,
widget: z.infer<typeof customWidgetImportSchema>,
@@ -6,17 +6,42 @@ import { describe, expect, test } from "vitest";
const languageDirectory = resolve(import.meta.dirname, "../../../translation/src/lang");
describe("Workshop navigation translations", () => {
test("defines the navbar and breadcrumb label in the source locale", async () => {
test("defines the breadcrumb labels for the Custom Widgets sub-routes", async () => {
const messages = JSON.parse(await readFile(resolve(languageDirectory, "en.json"), "utf8")) as {
management?: { navbar?: { items?: { workshop?: unknown } } };
navigationStructure?: { manage?: { workshop?: { label?: unknown } } };
navigationStructure?: {
manage?: {
workshop?: unknown;
"custom-widgets"?: {
workshop?: { label?: unknown };
publish?: { label?: unknown };
};
};
};
customWidget?: { page?: { tabs?: Record<string, unknown> } };
workshop?: Record<string, unknown>;
};
expect(messages.management?.navbar?.items?.workshop).toBe("Workshop");
expect(messages.navigationStructure?.manage?.workshop?.label).toBe("Workshop");
// The Workshop is reached through the Custom Widgets tabs, not its own navbar entry.
expect(messages.management?.navbar?.items?.workshop).toBeUndefined();
expect(messages.navigationStructure?.manage?.workshop).toBeUndefined();
expect(messages.navigationStructure?.manage?.["custom-widgets"]?.workshop?.label).toBe("Workshop");
expect(messages.navigationStructure?.manage?.["custom-widgets"]?.publish?.label).toBe("Publish to Workshop");
expect(messages.customWidget?.page?.tabs).toMatchObject({
installed: "Installed",
workshop: "Workshop",
ariaLabel: expect.any(String),
});
expect(messages.workshop).toMatchObject({
communityMember: "Community member",
outdated: "Outdated",
install: "Install",
back: expect.any(String),
listAriaLabel: expect.any(String),
installReviewTitle: expect.any(String),
emptyFilteredDescription: expect.any(String),
importCssConfirm: expect.any(String),
reportCount: expect.any(String),
reportWarning: expect.any(String),
reportVisibility: expect.any(String),
@@ -31,7 +56,19 @@ describe("Workshop navigation translations", () => {
inappropriate: expect.any(String),
other: expect.any(String),
}),
publish: expect.objectContaining({ invalidScreenshot: expect.any(String) }),
publish: expect.objectContaining({
invalidScreenshot: expect.any(String),
}),
});
});
test("drops the message keys that only existed for the stacked install modals", async () => {
const messages = JSON.parse(await readFile(resolve(languageDirectory, "en.json"), "utf8")) as {
workshop?: Record<string, unknown>;
};
for (const key of ["installDialog", "continueInstall", "confirmInstallTitle", "backToWidget", "inspectCss"]) {
expect(messages.workshop).not.toHaveProperty(key);
}
});
});
@@ -1,12 +1,11 @@
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";
import type { ImportReview } from "../core/import";
export interface ImportReviewMessages {
title: string;
export interface ImportReviewContentMessages {
description: string;
name: string;
origin: string;
@@ -16,9 +15,13 @@ export interface ImportReviewMessages {
permissions: string;
actionWarningTitle: string;
actionWarningDescription: string;
permission(permission: string): string;
}
export interface ImportReviewMessages extends ImportReviewContentMessages {
title: string;
cancel: string;
confirm: string;
permission(permission: string): string;
}
export interface ImportReviewDialogProps {
@@ -36,6 +39,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">
@@ -47,6 +66,71 @@ function ImportFact({ label, value }: { label: string; value: string }) {
);
}
export interface ImportReviewContentProps {
review: ImportReview;
messages: ImportReviewContentMessages;
children?: ReactNode;
}
/**
* The security facts a user has to review before a custom widget is installed.
* Shared by the import modal and the Workshop install page so both read identically.
*/
export function ImportReviewContent({ review, messages, children }: ImportReviewContentProps) {
return (
<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>
<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>
);
}
export function ImportReviewDialog({
opened,
review,
@@ -70,65 +154,31 @@ 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>
))}
<>
<Box p="lg" style={{ flex: 1, minHeight: 0, overflowY: "auto" }}>
<ImportReviewContent review={review} messages={messages}>
{children}
</ImportReviewContent>
</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>
</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}
</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>
</Box>
</>
)}
</Modal>
);
+1
View File
@@ -9,6 +9,7 @@
"exports": {
".": "./index.ts",
"./collection": "./collection.ts",
"./migrations/custom": "./migrations/custom/index.ts",
"./migrations/seed": "./migrations/seed.ts",
"./proxy-reader": "./proxy-reader.ts",
"./schema": "./schema/index.ts",
+47 -32
View File
@@ -1,5 +1,6 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import { Alert, Button, Center, Paper, Stack, Text, Title } from "@mantine/core";
@@ -13,13 +14,18 @@ import { Finish } from "./finish";
import { SetupStudio } from "./setup-studio";
import { Welcome } from "./welcome";
type ClaimState = "checking" | "ready" | "locked" | "signIn" | "error";
const getInitialClaimState = (accessState: ReturnType<typeof getOnboardingAccessState>): ClaimState => {
if (accessState === "claim") return "checking";
return accessState;
};
export const OnboardingStudio = (props: OnboardingStudioProps) => {
const t = useScopedI18n("init.studio.claim");
const tCommon = useScopedI18n("common.action");
const accessState = getOnboardingAccessState(props.environment);
const [claimState, setClaimState] = useState<"checking" | "ready" | "locked" | "signIn" | "error">(
accessState === "claim" ? "checking" : accessState,
);
const [claimState, setClaimState] = useState<ClaimState>(() => getInitialClaimState(accessState));
const [claimError, setClaimError] = useState<string | null>(null);
useEffect(() => {
@@ -56,43 +62,52 @@ export const OnboardingStudio = (props: OnboardingStudioProps) => {
}, [accessState, t]);
if (claimState !== "ready") {
const signIn = claimState === "signIn";
let title = t("errorTitle");
let description = claimError ?? t("errorDescription");
let action: ReactNode = (
<Stack align="center">
<Alert color="red" role="alert">
{description}
</Alert>
<Button onClick={() => window.location.reload()}>{tCommon("tryAgain")}</Button>
</Stack>
);
switch (claimState) {
case "checking":
title = t("checkingTitle");
description = t("checking");
action = null;
break;
case "signIn":
title = t("signInTitle");
description = t("signInDescription");
action = (
<Button component={Link} href="/auth/login?callbackUrl=%2Finit">
{t("signIn")}
</Button>
);
break;
case "locked":
title = t("lockedTitle");
description = t("lockedDescription");
action = <Button onClick={() => window.location.reload()}>{tCommon("tryAgain")}</Button>;
break;
case "error":
break;
}
return (
<Center mih="100dvh" p="md">
<Paper withBorder radius="lg" p="xl" maw="32rem" w="100%">
<Stack align="center">
<Title order={1} size="h2" ta="center">
{claimState === "checking"
? t("checkingTitle")
: signIn
? t("signInTitle")
: claimState === "locked"
? t("lockedTitle")
: t("errorTitle")}
{title}
</Title>
<Text c="dimmed" ta="center">
{signIn
? t("signInDescription")
: claimState === "locked"
? t("lockedDescription")
: claimState === "checking"
? t("checking")
: (claimError ?? t("errorDescription"))}
{description}
</Text>
{signIn ? (
<Button component={Link} href="/auth/login?callbackUrl=%2Finit">
{t("signIn")}
</Button>
) : claimState === "locked" ? (
<Button onClick={() => window.location.reload()}>{tCommon("tryAgain")}</Button>
) : claimState === "error" ? (
<Stack align="center">
<Alert color="red" role="alert">
{claimError ?? t("errorDescription")}
</Alert>
<Button onClick={() => window.location.reload()}>{tCommon("tryAgain")}</Button>
</Stack>
) : null}
{action}
</Stack>
</Paper>
</Center>
+59 -44
View File
@@ -889,12 +889,17 @@
"list": {
"title": "Custom Widgets",
"noResults": "No custom widgets yet. Create your first one!",
"noDescription": "No description",
"loading": "Loading...",
"enabled": "Enabled",
"disabled": "Disabled",
"disabledDescription": "This widget stays configured but will not load on any board.",
"requestCount": "{count, plural, one {# request} other {# requests}}",
"invalidDefinition": "Invalid definition",
"invalidDefinitionDescription": "This stored widget no longer matches the current Custom Widget schema. Delete it and recreate or import a valid definition.",
"migrationRequired": "Migration required",
"migrationDescription": "This v1 custom widget is preserved but cannot run until it is migrated to Custom JSX v2.",
"migrationInstructions": "Open Actions, copy the redacted migration prompt into an LLM, then copy its single JSON response and choose Paste migrated widget. Existing board placements keep working after migration.",
"migrationInstructions": "Open Actions to copy or export the original v1 definition for manual recreation. You can also use the migration prompt with an LLM. Existing board placements keep working after migration.",
"missingCredentials": "{count, plural, one {Credential missing} other {# credentials missing}}",
"missingCredentialsDescription": "{count, plural, one {This widget still needs one credential.} other {This widget still needs # credentials.}} Edit it to configure the encrypted values."
},
@@ -904,6 +909,11 @@
},
"edit": {
"title": "Edit Custom Widget"
},
"tabs": {
"ariaLabel": "Custom widget sections",
"installed": "Installed",
"workshop": "Workshop"
}
},
"action": {
@@ -924,6 +934,8 @@
"downloadSkill": "View Custom Widget Skill",
"publishWorkshop": "Publish to Workshop",
"migrate": "Migrate Custom Widget",
"copyLegacyDefinition": "Copy original widget content",
"exportLegacyDefinition": "Export original widget content",
"copyMigrationPrompt": "Copy LLM migration prompt",
"pasteMigration": "Paste migrated widget",
"importMigration": "Import migrated JSON file",
@@ -1282,6 +1294,8 @@
"imported": "Widget imported successfully.",
"migrated": "Legacy widget migrated successfully. Existing board placements now use the v2 definition.",
"migrationError": "Failed to migrate the legacy widget. Ensure the file uses the homarr-custom-widget-v2 schema.",
"legacyDefinitionCopied": "The original widget content was copied. Stored credential values are not included.",
"legacyDefinitionCopyError": "Failed to copy the original widget content.",
"migrationPromptCopied": "A redacted migration prompt was copied. Paste it into an LLM, copy its single JSON response, then choose Paste migrated widget here.",
"migrationPromptCopyError": "Failed to copy the migration prompt.",
"aiPromptCopied": "AI prompt copied to clipboard",
@@ -1436,64 +1450,68 @@
}
},
"workshop": {
"installSuccess": "Custom widget installed",
"installSuccessDescription": "Add its credentials, test the requests, and save when it is ready.",
"installError": "Could not install widget",
"installErrorDescription": "The Workshop submission is not a valid Custom JSX v2 widget.",
"title": "Workshop",
"importFromWorkshop": "Import from Workshop",
"browseWorkshop": "Browse Workshop",
"openCommunity": "Open Workshop",
"manageSubmissionsTitle": "Manage submissions on Workshop",
"manageSubmissionsDescription": "Publishing starts in Homarr, but updates, reports, outdated status, and deletion are managed directly on the Workshop website. Sign in there and select Yours to find your submissions.",
"description": "Community Custom JSX v2 widgets. Inspect the source and network capabilities before installing.",
"descriptionCss": "Community Custom CSS themes. Preview every rule before importing it into a board.",
"securityNotice": "Workshop widgets can call their declared API sources and perform declared actions with credentials you configure locally. Secrets are never included.",
"securityNoticeCss": "Community CSS can change the appearance and usability of a board. Review it before importing; nothing is saved until you save board settings.",
"signIn": "Sign in with GitHub",
"signInError": "GitHub sign-in failed. Check that the Workshop OAuth provider is configured and that popups are allowed.",
"signOut": "Sign out",
"openOnWorkshop": "Open {title} on the Workshop website",
"back": "Back to Workshop",
"cancel": "Cancel",
"search": "Search widgets",
"searchCss": "Search CSS themes",
"sort": "Sort",
"sortTop": "Top rated",
"sortNewest": "Newest",
"listAriaLabel": "Workshop widgets",
"listAriaLabelCss": "Workshop CSS themes",
"empty": "No widgets match this search.",
"emptyDescription": "Community widgets published to the Workshop show up here.",
"emptyFilteredTitle": "No widgets match this search",
"emptyFilteredDescription": "No Workshop widget matches \"{search}\".",
"clearSearch": "Clear search",
"emptyCss": "No CSS themes match this search.",
"unavailableTitle": "Homarr Workshop seems to be unavailable",
"unavailable": "The community service may be temporarily down. Try again in a moment. Your installed widgets are unaffected.",
"retry": "Try again",
"empty": "No widgets match this search.",
"emptyCss": "No CSS themes match this search.",
"loadError": "This submission could not be loaded.",
"noDescription": "No description",
"author": "by {name}",
"communityMember": "Community member",
"outdated": "Outdated",
"outdatedWarning": "The author marked this submission as outdated. Review it carefully before importing.",
"reportCount": "{count, plural, one {# report} other {# reports}}",
"reportWarning": "{count, plural, one {This submission has # open community report.} other {This submission has # open community reports.}} It remains installable, but you should review its content first.",
"reportVisibility": "Report details are visible only to this submission's author and Workshop admins.",
"score": "Score: {count, number}",
"upvote": "Upvote",
"downvote": "Downvote",
"voteError": "The vote could not be saved.",
"signIn": "Sign in with GitHub",
"signInError": "GitHub sign-in failed. Check that the Workshop OAuth provider is configured and that popups are allowed.",
"signOut": "Sign out",
"signInHint": "Sign in to vote or report. Installing does not require a Workshop account.",
"securityNotice": "Workshop widgets can call their declared API sources and perform declared actions with credentials you configure locally. Secrets are never included.",
"securityNoticeCss": "Community CSS can change the appearance and usability of a board. Review it before importing; nothing is saved until you save board settings.",
"install": "Install",
"installReviewTitle": "Review before installing",
"installError": "This widget cannot be installed",
"installErrorDescription": "The Workshop submission is not a valid Custom JSX v2 widget.",
"useCss": "Import CSS",
"importCss": "Import CSS from Workshop",
"importCssConfirm": "Replace the current custom CSS with \"{title}\"? Nothing is saved until you save the board settings.",
"screenshotAlt": "{title} screenshot {count, number}",
"upvote": "Upvote, {count, plural, one {# vote} other {# votes}}",
"downvote": "Downvote, {count, plural, one {# vote} other {# votes}}",
"inspectCss": "Inspect CSS",
"zoomScreenshot": "View full size",
"previousScreenshot": "Previous screenshot",
"nextScreenshot": "Next screenshot",
"technicalDetails": "Technical details",
"sourceCode": "Workshop entry",
"loadError": "This submission could not be loaded.",
"sources": "Declared API sources",
"requests": "Declared queries and actions",
"noRequests": "This widget does not declare network requests.",
"export": "Export",
"report": "Report",
"install": "Install widget",
"continueInstall": "Continue installation",
"confirmInstallTitle": "Confirm widget installation",
"confirmInstallDescription": "Confirm where this widget connects and add any credentials required by your Homarr installation.",
"backToWidget": "Back to widget",
"useCss": "Import CSS",
"signInHint": "Sign in to vote or report. Installing does not require a Workshop account.",
"reportTitle": "Report submission",
"reportReason": "Reason",
"reportExplanation": "What should the Workshop moderators know?",
"reportSend": "Send report",
"reportSent": "Report sent",
"reportSentDescription": "Workshop moderators can now review this submission.",
"reportCount": "{count, plural, one {# report} other {# reports}}",
"reportWarning": "{count, plural, one {This submission has # open community report.} other {This submission has # open community reports.}} It remains installable, but you should review its content first.",
"reportVisibility": "Report details are visible only to this submission's author and Workshop admins.",
"reportCategory": {
"outdated": "Outdated or no longer working",
"malicious": "Malicious",
@@ -1502,7 +1520,6 @@
"inappropriate": "Inappropriate",
"other": "Other"
},
"voteError": "The vote could not be saved.",
"publish": {
"title": "Publish to Workshop",
"introTitle": "Share this widget with the community",
@@ -1511,7 +1528,6 @@
"listingDetailsDescription": "These details appear publicly alongside the exported widget definition.",
"success": "Published immediately. Community members can now install and report this widget.",
"successTitle": "Your widget is live",
"manageTitle": "Continue on Workshop",
"manageDescription": "Open the submission to update its listing, review reports, mark it outdated, or delete it. Submission management is handled directly on the Workshop website.",
"viewSubmission": "View and manage submission",
"done": "Done",
@@ -1530,10 +1546,7 @@
"publicationNote": "Publishing is immediate. You can manage the submission afterward on Workshop.",
"definitionChanged": "The widget changed while publication was being prepared. Review its private source URLs again, then retry.",
"error": "Publication failed"
},
"installDialog": "Install from Workshop",
"installed": "Widget installed",
"installedDescription": "{name} is ready to configure."
}
},
"integration": {
"completion": {
@@ -6128,7 +6141,6 @@
"apps": "Apps",
"integrations": "Integrations",
"customWidgets": "Custom Widgets",
"workshop": "Workshop",
"searchEngies": "Search engines",
"medias": "Medias",
"users": {
@@ -7614,11 +7626,14 @@
"flow": {
"label": "Flow"
}
},
"workshop": {
"label": "Workshop"
},
"publish": {
"label": "Publish to Workshop"
}
},
"workshop": {
"label": "Workshop"
},
"users": {
"label": "Users",
"create": {
@@ -1,41 +1,9 @@
.root {
--integration-marquee-accent: var(--mantine-primary-color-filled);
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--mantine-spacing-xl);
height: 100%;
overflow: hidden;
transform: rotate(10deg);
}
.track {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--mantine-spacing-xl);
animation: integrationMarquee var(--marquee-duration) linear infinite;
}
.iconFrame {
display: grid;
flex: 0 0 4.5rem;
width: 4.5rem;
height: 4.5rem;
place-items: center;
border: 1px solid color-mix(in srgb, var(--integration-marquee-accent) 24%, transparent);
border-radius: var(--mantine-radius-lg);
background: color-mix(in srgb, var(--integration-marquee-accent) 8%, var(--mantine-color-body));
box-shadow: var(--mantine-shadow-sm);
}
@keyframes integrationMarquee {
to {
transform: translateY(-50%);
}
.marquee {
mask-image: linear-gradient(to bottom, transparent, black 14%, black 86%, transparent);
}
@media (prefers-reduced-motion: reduce) {
.track {
.content {
animation: none;
}
}
@@ -1,5 +1,4 @@
import type { CSSProperties } from "react";
import { Image } from "@mantine/core";
import { Image, Marquee, SimpleGrid } from "@mantine/core";
import { splitToNChunks } from "@homarr/common";
import { integrationDefs } from "@homarr/definitions";
@@ -15,19 +14,31 @@ const iconGroups = splitToNChunks(
const animationDuration = iconGroups.flat().length * 2;
export const IntegrationMarquee = ({ className }: { className?: string }) => (
<div className={[classes.root, className].filter(Boolean).join(" ")} aria-hidden>
<SimpleGrid
className={className}
cols={3}
spacing="xl"
h="calc(100% + 6rem)"
my="-3rem"
style={{ transform: "rotate(10deg)" }}
aria-hidden
>
{iconGroups.map((icons, columnIndex) => (
<div
<Marquee
key={columnIndex}
className={classes.track}
style={{ "--marquee-duration": `${animationDuration - columnIndex * 3}s` } as CSSProperties}
orientation="vertical"
repeat={2}
duration={(animationDuration - columnIndex * 3) * 1000}
gap="xl"
fadeEdges={false}
h="100%"
className={classes.marquee}
classNames={{ content: classes.content }}
>
{[...icons, ...icons].map((icon, iconIndex) => (
<div key={`${icon}-${iconIndex}`} className={classes.iconFrame}>
<Image src={icon} alt="" fit="contain" w={52} h={52} />
</div>
{icons.map((icon) => (
<Image key={`${icon}-${columnIndex}`} src={icon} alt="" fit="contain" w={52} h={52} />
))}
</div>
</Marquee>
))}
</div>
</SimpleGrid>
);
@@ -135,7 +135,7 @@ export default function BeszelAlertsWidget({
{alerts.length === 0 && (
<Stack align="center" justify="center" py="xl" gap="xs">
<ThemeIcon variant="light" color="gray" size="lg" radius="xl">
<IconBellOff size={18} />
<IconBellOff size="var(--mantine-font-size-lg)" />
</ThemeIcon>
<Text size="sm" c="dimmed">
{t("empty")}
@@ -146,7 +146,7 @@ export default function BeszelAlertsWidget({
{triggeredAlerts.length > 0 && (
<Stack gap={6}>
<Group gap={6}>
<IconFlame size={14} color="var(--mantine-color-red-6)" />
<IconFlame size="var(--mantine-font-size-sm)" color="var(--mantine-color-red-6)" />
<Text size="xs" fw={600} c="red">
{t("status.triggered")} ({triggeredAlerts.length})
</Text>
@@ -171,7 +171,7 @@ export default function BeszelAlertsWidget({
{showOkAlerts && okAlerts.length > 0 && (
<Stack gap={6}>
<Group gap={6}>
<IconCircleCheck size={14} color="var(--mantine-color-green-6)" />
<IconCircleCheck size="var(--mantine-font-size-sm)" color="var(--mantine-color-green-6)" />
<Text size="xs" fw={600} c="dimmed">
{t("status.ok")} ({okAlerts.length})
</Text>
@@ -196,7 +196,7 @@ export default function BeszelAlertsWidget({
<Divider />
<Stack gap={6}>
<Group gap={6}>
<IconHistory size={14} opacity={0.5} />
<IconHistory size="var(--mantine-font-size-sm)" opacity={0.5} />
<Text size="xs" fw={600} c="dimmed">
{t("history")}
</Text>
@@ -215,7 +215,7 @@ export default function BeszelAlertsWidget({
style={{ borderRadius: 2, flexShrink: 0 }}
bg={isResolved ? "green.6" : "red.6"}
/>
<HistoryIcon size={12} opacity={0.5} style={{ flexShrink: 0 }} />
<HistoryIcon size="var(--mantine-font-size-xs)" opacity={0.5} style={{ flexShrink: 0 }} />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="xs" fw={500} truncate>
{entry.name}
@@ -229,7 +229,7 @@ export default function BeszelAlertsWidget({
<Badge size="xs" variant="dot" color={isResolved ? "green" : "red"}>
{isResolved ? t("resolved") : t("status.triggered")}
</Badge>
<Text size="10px" c="dimmed">
<Text size="xs" c="dimmed">
{dayjs(entry.created).fromNow()}
</Text>
</Stack>
@@ -292,7 +292,7 @@ function AlertRow({ name, value, min, systemName, integrationName, triggered, sh
backgroundColor: triggered ? "var(--mantine-color-red-light)" : "var(--mantine-color-default-hover)",
}}
>
<Icon size={14} opacity={0.7} style={{ flexShrink: 0 }} />
<Icon size="var(--mantine-font-size-sm)" opacity={0.7} style={{ flexShrink: 0 }} />
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text size="xs" fw={600} truncate>
@@ -59,7 +59,7 @@ interface SizeConfig {
const defaultSizeConfig: SizeConfig = {
iconSize: 10,
fontSize: "10px",
fontSize: "xs",
progressSize: "xs",
labelMiw: 40,
valueMiw: 30,
@@ -418,7 +418,7 @@ const SystemCard = ({
{visibleMetrics.map((m) => m.render(system, t, size))}
</Stack>
{hiddenMetricCount > 0 && (
<Text size="10px" c="dimmed" ta="right">
<Text size="xs" c="dimmed" ta="right">
+{hiddenMetricCount}
</Text>
)}
@@ -428,7 +428,7 @@ const SystemCard = ({
const SystemMetadata = ({ label, value }: { label: string; value: string }) => (
<Box style={{ minWidth: 0 }}>
<Text size="10px" c="dimmed">
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="xs" fw={500} truncate title={value}>
@@ -88,7 +88,7 @@ export function padLiveTimeGrid(data: Record<string, unknown>[], pointCount = 60
});
}
const yAxisBase = { tickMargin: 0, tick: { fontSize: 10 } } as const;
const yAxisBase = { tickMargin: 0, tick: { fontSize: "var(--mantine-font-size-xs)" } } as const;
const chartStyle = { minWidth: 0, minHeight: 1 } as const;
const panelStyle = { minWidth: 0, overflow: "hidden" } as const;
export const CPU_Y_AXIS_DOMAIN: [number, string] = [0, "auto"];
@@ -94,7 +94,7 @@ export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap =
<Stack gap={6} miw={145}>
{[["/", system.disk] as const, ...filesystems].map(([path, value]) => (
<Stack key={path} gap={1}>
<Text size="10px" c="dimmed" fw={500} lh={1.2}>
<Text size="xs" c="dimmed" fw={500} lh={1.2}>
{mountLabel(path)}
</Text>
<Group gap={8} wrap="nowrap">
@@ -11,7 +11,7 @@ const styles: Record<string, CSSProperties> = {
border: "1px solid var(--mantine-color-dark-4)",
borderRadius: 6,
padding: "6px 10px",
fontSize: 12,
fontSize: "var(--mantine-font-size-xs)",
lineHeight: 1.5,
pointerEvents: "none",
overflowX: "hidden",
+16 -1
View File
@@ -657,6 +657,8 @@ export const workshopQueryKeys = {
] as const,
comments: (backend: WorkshopBackend, submission: string) =>
[...workshopQueryKeys.all, "comments", backend.baseUrl, submission] as const,
userVotes: (backend: WorkshopBackend) =>
[...workshopQueryKeys.all, "user-votes", backend.baseUrl, backend.currentUser?.id ?? "anonymous"] as const,
};
export const workshopListQueryOptions = (backend: WorkshopBackend, options: WorkshopListOptions = {}) =>
@@ -683,6 +685,14 @@ export const workshopReportSummariesQueryOptions = (backend: WorkshopBackend, su
enabled: submission.length > 0,
});
/** The votes the signed-in user already cast, so controls can render their own choice. */
export const workshopUserVotesQueryOptions = (backend: WorkshopBackend) =>
queryOptions({
queryKey: workshopQueryKeys.userVotes(backend),
queryFn: () => backend.listVotesForCurrentUser(),
enabled: backend.currentUser !== null,
});
export const workshopCommentsQueryOptions = (backend: WorkshopBackend, submission: string) =>
queryOptions({
queryKey: workshopQueryKeys.comments(backend, submission),
@@ -771,12 +781,17 @@ export function useWorkshopCommentsQuery(backend: WorkshopBackend, submission: s
return useQuery(workshopCommentsQueryOptions(backend, submission));
}
export function useWorkshopUserVotesQuery(backend: WorkshopBackend) {
return useQuery(workshopUserVotesQueryOptions(backend));
}
function useWorkshopInvalidation(backend: WorkshopBackend) {
const queryClient = useQueryClient();
return {
lists: () => queryClient.invalidateQueries({ queryKey: workshopQueryKeys.lists(backend.baseUrl) }),
details: () => queryClient.invalidateQueries({ queryKey: workshopQueryKeys.details(backend.baseUrl) }),
detail: (id: string) => queryClient.invalidateQueries({ queryKey: workshopQueryKeys.detail(backend, id) }),
userVotes: () => queryClient.invalidateQueries({ queryKey: workshopQueryKeys.userVotes(backend) }),
removeDetail: (id: string) => queryClient.removeQueries({ queryKey: workshopQueryKeys.detail(backend, id) }),
reports: () => queryClient.invalidateQueries({ queryKey: workshopQueryKeys.reports(backend) }),
reportSummaries: (submission: string) =>
@@ -814,7 +829,7 @@ export function useWorkshopVoteMutation(backend: WorkshopBackend) {
const invalidation = useWorkshopInvalidation(backend);
return useMutation({
...workshopVoteMutationOptions(backend),
onSuccess: async () => Promise.all([invalidation.lists(), invalidation.details()]),
onSuccess: async () => Promise.all([invalidation.lists(), invalidation.details(), invalidation.userVotes()]),
});
}