feat: rebuild onboarding experience

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-14 11:32:11 +02:00
1 parent d091362db2
commit e41605640b
249 files changed
+8216 -14060

No files matched your search

@@ -4,9 +4,9 @@ import { Box, Paper, Stack, Text } from "@mantine/core";
import { useCurrentLayout, useInitialViewportWidth, useLayoutOverride, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { getRepresentativeLayoutWidth } from "@homarr/boards/layout-preview";
import { useScopedI18n } from "@homarr/translation/client";
import { getRepresentativeLayoutWidth } from "../_layout-utils";
import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context";
import {
getBoardLaneColumnCount,
@@ -4,21 +4,21 @@ import { Badge, Box, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/cor
import { IconLayoutGrid } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import type { BoardPreviewLayout } from "@homarr/boards/layout-preview";
import { getRepresentativeLayoutWidth, projectBoardLayout } from "@homarr/boards/layout-preview";
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import type { Board } from "../../_types";
import type { BoardLayout } from "../../_layout-utils";
import { getRepresentativeLayoutWidth, projectBoardLayout } from "../../_layout-utils";
import classes from "./_layout-preview.module.css";
interface Props {
board: Board;
layout: BoardLayout;
layouts: BoardLayout[];
sourceLayout: BoardLayout;
layout: BoardPreviewLayout;
layouts: BoardPreviewLayout[];
sourceLayout: BoardPreviewLayout;
apps: RouterOutputs["app"]["byIds"];
}
@@ -1,111 +0,0 @@
import { describe, expect, test } from "vitest";
import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock";
import { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-section-mock";
import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock";
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
import { getRepresentativeLayoutWidth, projectBoardLayout } from "./_layout-utils";
const mobile = {
id: "mobile",
name: "Mobile",
columnCount: 3,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 0,
role: "mobile" as const,
};
const custom = {
id: "tablet",
name: "Tablet",
columnCount: 6,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 480,
role: "custom" as const,
};
const base = {
id: "base",
name: "Base",
columnCount: 12,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
breakpoint: 768,
role: "base" as const,
};
describe("layout representative widths", () => {
test("uses a phone width, activation midpoint, and desktop minimum", () => {
const layouts = [mobile, custom, base];
expect(getRepresentativeLayoutWidth(mobile, layouts)).toBe(390);
expect(getRepresentativeLayoutWidth(custom, layouts)).toBe(624);
expect(getRepresentativeLayoutWidth(base, layouts)).toBe(1280);
});
});
describe("projectBoardLayout", () => {
test("returns exact saved positions without recursively re-projecting the active layout", () => {
const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
const container = new ContainerSectionMockBuilder({ id: "container" })
.addLayout({ layoutId: mobile.id, parentSectionId: rootSection.id, width: 3, height: 2 })
.build();
const board = new BoardMockBuilder().addLayout(mobile).addSections([rootSection, container]).build();
expect(projectBoardLayout(board, mobile, mobile)).toEqual([
expect.objectContaining({ id: container.id, sectionId: rootSection.id, width: 3, height: 2 }),
]);
});
test("uses production ordering and sizing for items and containers", () => {
const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
const container = new ContainerSectionMockBuilder({ id: "container" })
.addLayout({ layoutId: base.id, parentSectionId: rootSection.id, width: 6, height: 3, xOffset: 6, yOffset: 0 })
.build();
const rootItem = new ItemMockBuilder({ id: "root-item", kind: "clock" })
.addLayout({ layoutId: base.id, sectionId: rootSection.id, width: 5, height: 2, xOffset: 0, yOffset: 0 })
.build();
const nestedItem = new ItemMockBuilder({ id: "nested-item", kind: "clock" })
.addLayout({ layoutId: base.id, sectionId: container.id, width: 4, height: 1, xOffset: 0, yOffset: 0 })
.build();
const board = new BoardMockBuilder()
.addLayout(mobile)
.addSections([rootSection, container])
.addItems([rootItem, nestedItem])
.build();
const projected = projectBoardLayout(board, base, mobile);
expect(projected.find((element) => element.id === rootItem.id)).toMatchObject({
type: "item",
width: 3,
height: 2,
xOffset: 0,
yOffset: 0,
sectionId: rootSection.id,
});
expect(projected.find((element) => element.id === container.id)).toMatchObject({
type: "section",
width: 3,
xOffset: 0,
yOffset: 2,
sectionId: rootSection.id,
});
expect(projected.find((element) => element.id === nestedItem.id)).toMatchObject({
type: "item",
width: 3,
sectionId: container.id,
});
});
test("ignores missing saved positions and supports empty boards", () => {
const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
const itemWithoutBasePosition = new ItemMockBuilder({ id: "missing", kind: "clock" }).build();
const board = new BoardMockBuilder()
.addLayout(mobile)
.addSection(rootSection)
.addItem(itemWithoutBasePosition)
.build();
expect(projectBoardLayout(board, base, mobile)).toEqual([]);
});
});
@@ -1,122 +0,0 @@
import { generateResponsiveGridFor } from "@homarr/common";
import type { GridAlgorithmItem } from "@homarr/common";
import type { BoardLane } from "@homarr/definitions";
import { boardLanes, getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
import type { Board } from "./_types";
export type BoardLayout = Board["layouts"][number];
type LayoutGeometry = Pick<BoardLayout, "id" | "columnCount" | "leftGutterColumnCount" | "rightGutterColumnCount">;
export const getRepresentativeLayoutWidth = (layout: BoardLayout, allLayouts: BoardLayout[]) => {
if (layout.role === "mobile") return 390;
if (layout.role === "base") return Math.max(1280, layout.breakpoint);
const nextLayout = allLayouts
.filter((candidate) => candidate.breakpoint > layout.breakpoint)
.toSorted((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint)
.at(0);
return nextLayout ? Math.round((layout.breakpoint + nextLayout.breakpoint) / 2) : Math.max(1280, layout.breakpoint);
};
export const projectBoardLayout = (board: Board, sourceLayout: LayoutGeometry, targetLayout: LayoutGeometry) => {
const elements = getElementsForLayout(board, sourceLayout.id);
if (
sourceLayout.id === targetLayout.id &&
sourceLayout.columnCount === targetLayout.columnCount &&
sourceLayout.leftGutterColumnCount === targetLayout.leftGutterColumnCount &&
sourceLayout.rightGutterColumnCount === targetLayout.rightGutterColumnCount
) {
return elements;
}
const emptyRoots = board.sections
.filter((section) => section.kind === "empty")
.toSorted((first, second) => first.xOffset - second.xOffset || first.id.localeCompare(second.id));
const rootByLane = new Map<BoardLane, (typeof emptyRoots)[number]>();
for (const root of emptyRoots) {
const lane = getRootSectionLane(root.xOffset);
if (!rootByLane.has(lane)) rootByLane.set(lane, root);
}
const mainRoot = rootByLane.get("main");
if (!mainRoot) return [];
const sourceRootById = new Map(
emptyRoots.map((section) => [section.id, getRootSectionLane(section.xOffset)] as const),
);
const targetLaneBySourceLane = new Map<BoardLane, BoardLane>(
boardLanes.map((lane) => [
lane,
lane !== "main" && getBoardLaneColumnCount(targetLayout, lane) === 0 ? "main" : lane,
]),
);
const remappedElements = elements.map((element) => {
const sourceLane = sourceRootById.get(element.sectionId);
if (!sourceLane) return element;
const targetLane = targetLaneBySourceLane.get(sourceLane) ?? "main";
return { ...element, sectionId: (rootByLane.get(targetLane) ?? mainRoot).id };
});
return boardLanes.flatMap((lane) => {
const root = rootByLane.get(lane);
const width = getBoardLaneColumnCount(targetLayout, lane);
if (!root || width === 0) return [];
const sourceLanes = boardLanes.filter(
(sourceLane) =>
rootByLane.has(sourceLane) &&
getBoardLaneColumnCount(sourceLayout, sourceLane) > 0 &&
targetLaneBySourceLane.get(sourceLane) === lane,
);
const previousWidth =
sourceLanes.length === 1
? getBoardLaneColumnCount(sourceLayout, sourceLanes[0] ?? "main")
: Number.MAX_SAFE_INTEGER;
return generateResponsiveGridFor({
items: remappedElements,
previousWidth,
width,
sectionId: root.id,
}).items;
});
};
const getElementsForLayout = (board: Board, layoutId: string): GridAlgorithmItem[] => [
...board.items.flatMap((item) => {
const layout = item.layouts.find((candidate) => candidate.layoutId === layoutId);
return layout
? [
{
id: item.id,
type: "item" as const,
height: layout.height,
width: layout.width,
xOffset: layout.xOffset,
yOffset: layout.yOffset,
sectionId: layout.sectionId,
},
]
: [];
}),
...board.sections.flatMap((section) => {
if (section.kind !== "container") return [];
const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
return layout?.parentSectionId
? [
{
id: section.id,
type: "section" as const,
height: layout.height,
width: layout.width,
xOffset: layout.xOffset,
yOffset: layout.yOffset,
sectionId: layout.parentSectionId,
},
]
: [];
}),
];
@@ -1,12 +0,0 @@
import type { PropsWithChildren } from "react";
import { Card } from "@mantine/core";
interface InitStepCardProps {
withBorder?: boolean;
}
export const InitStepCard = ({ children, withBorder = true }: PropsWithChildren<InitStepCardProps>) => (
<Card w="100%" withBorder={withBorder}>
{children}
</Card>
);
@@ -1,2 +0,0 @@
export const onboardingContentWidth = 64 * 16;
export const onboardingMaxWidth = "90vw";
@@ -1,23 +0,0 @@
"use client";
import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useI18n } from "@homarr/translation/client";
export const BackToStart = () => {
const t = useI18n();
const { mutateAsync, isPending } = clientApi.onboard.previousStep.useMutation();
const handleBackToStartAsync = async () => {
await mutateAsync();
await revalidatePathActionAsync("/init");
};
return (
<Button loading={isPending} variant="subtle" color="gray" fullWidth onClick={handleBackToStartAsync}>
{t("init.backToStart")}
</Button>
);
};
@@ -1,75 +0,0 @@
import type { MantineColor } from "@mantine/core";
import { Button, Stack, Text } from "@mantine/core";
import { IconBook2, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react";
import { isProviderEnabled } from "@homarr/auth/server";
import { getMantineColor } from "@homarr/common";
import { createDocumentationLink } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui";
import { InitStepCard } from "../../_components/init-step-card";
export const InitFinish = async () => {
const tFinish = await getScopedI18n("init.step.finish");
return (
<InitStepCard>
<Stack>
<Text>{tFinish("description")}</Text>
<InternalLinkButton href="/auth/login?callbackUrl=/" iconProps={{ icon: IconLayoutDashboard, color: "blue" }}>
{tFinish("action.goToBoard", { name: "dashboard" })}
</InternalLinkButton>
{isProviderEnabled("credentials") && (
<InternalLinkButton
href="/auth/login?callbackUrl=/manage/users/invites"
iconProps={{ icon: IconMailForward, color: "pink" }}
>
{tFinish("action.inviteUser")}
</InternalLinkButton>
)}
<ExternalLinkButton
href={createDocumentationLink("/docs/getting-started/after-the-installation")}
iconProps={{ icon: IconBook2, color: "yellow" }}
>
{tFinish("action.docs")}
</ExternalLinkButton>
</Stack>
</InitStepCard>
);
};
interface LinkButtonProps {
href: string;
children: string;
iconProps: IconProps;
}
interface IconProps {
icon: TablerIcon;
color: MantineColor;
}
const Icon = ({ icon: IcomComponent, color }: IconProps) => {
return <IcomComponent color={getMantineColor(color, 6)} size={16} stroke={1.5} />;
};
const InternalLinkButton = ({ href, children, iconProps }: LinkButtonProps) => {
return (
<Button variant="default" component={Link} href={href} leftSection={<Icon {...iconProps} />}>
{children}
</Button>
);
};
const ExternalLinkButton = ({ href, children, iconProps }: LinkButtonProps) => {
return (
<Button variant="default" component="a" href={href} leftSection={<Icon {...iconProps} />}>
{children}
</Button>
);
};
@@ -1,55 +0,0 @@
"use client";
import { Button, Stack, TextInput } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react";
import type { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { groupCreateSchema } from "@homarr/validation/group";
import { InitStepCard } from "../../_components/init-step-card";
export const InitGroup = () => {
const t = useI18n();
const { mutateAsync } = clientApi.group.createInitialExternalGroup.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/init");
},
});
const form = useZodForm(groupCreateSchema, {
initialValues: {
name: "",
},
});
const handleSubmitAsync = async (values: z.infer<typeof groupCreateSchema>) => {
await mutateAsync(values, {
onError(error) {
if (error.data?.code === "CONFLICT") {
form.setErrors({ name: t("common.zod.errors.custom.groupNameTaken") });
}
},
});
};
return (
<InitStepCard>
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
<Stack>
<TextInput
label={t("init.step.group.form.name.label")}
description={t("init.step.group.form.name.description")}
withAsterisk
{...form.getInputProps("name")}
/>
<Button type="submit" loading={form.submitting} rightSection={<IconArrowRight size={16} stroke={1.5} />}>
{t("common.action.continue")}
</Button>
</Stack>
</form>
</InitStepCard>
);
};
@@ -1,43 +0,0 @@
import { ActionIcon, Button, Group, Text } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import { IconPencil } from "@tabler/icons-react";
import { formatBytes } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { InitStepCard } from "../../_components/init-step-card";
interface FileInfoCardProps {
file: FileWithPath;
onRemove: () => void;
}
export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
const tFileInfo = useScopedI18n("init.step.import.fileInfo");
return (
<InitStepCard withBorder={false}>
<Group justify="space-between" align="center" wrap="nowrap">
<Group>
<Text fw={500} lineClamp={1} style={{ wordBreak: "break-all" }}>
{file.name}
</Text>
<Text visibleFrom="md" c="gray.6" size="sm">
{formatBytes(file.size)}
</Text>
</Group>
<Button
variant="subtle"
color="gray"
rightSection={<IconPencil size={16} stroke={1.5} />}
onClick={onRemove}
visibleFrom="md"
>
{tFileInfo("action.change")}
</Button>
<ActionIcon size="sm" variant="subtle" color="gray" hiddenFrom="md" onClick={onRemove}>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
</Group>
</InitStepCard>
);
};
@@ -1,63 +0,0 @@
import { Group, rem, Text } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import { Dropzone, MIME_TYPES } from "@mantine/dropzone";
import { IconFileZip, IconUpload, IconX } from "@tabler/icons-react";
import "@mantine/dropzone/styles.css";
import { useScopedI18n } from "@homarr/translation/client";
interface ImportDropZoneProps {
loading: boolean;
updateFile: (file: FileWithPath) => void;
}
export const ImportDropZone = ({ loading, updateFile }: ImportDropZoneProps) => {
const tDropzone = useScopedI18n("init.step.import.dropzone");
return (
<Dropzone
onDrop={(files) => {
const firstFile = files[0];
if (!firstFile) return;
updateFile(firstFile);
}}
acceptColor="blue.6"
rejectColor="red.6"
accept={[MIME_TYPES.zip, "application/x-zip-compressed"]}
loading={loading}
multiple={false}
maxSize={1024 * 1024 * 1024 * 64} // 64 MB
onReject={(rejections) => {
console.error(
"Rejected files",
rejections.map(
(rejection) =>
`File: ${rejection.file.name} size=${rejection.file.size} fileType=${rejection.file.type}\n - ${rejection.errors.map((error) => error.message).join("\n - ")}`,
),
);
}}
>
<Group justify="center" gap="xl" mih={220} style={{ pointerEvents: "none" }}>
<Dropzone.Accept>
<IconUpload style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-blue-6)" }} stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-red-6)" }} stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconFileZip style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-dimmed)" }} stroke={1.5} />
</Dropzone.Idle>
<div>
<Text size="xl" inline>
{tDropzone("title")}
</Text>
<Text size="sm" c="dimmed" inline mt={7}>
{tDropzone("description")}
</Text>
</div>
</Group>
</Dropzone>
);
};
@@ -1,54 +0,0 @@
"use client";
import { startTransition, useState } from "react";
import { Stack } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { InitialOldmarrImport } from "@homarr/old-import/components";
import { InitStepCard } from "../../_components/init-step-card";
import { FileInfoCard } from "./file-info-card";
import { ImportDropZone } from "./import-dropzone";
export const InitImport = () => {
const [file, setFile] = useState<FileWithPath | null>(null);
const { isPending, mutate } = clientApi.import.analyseInitialOldmarrImport.useMutation();
const [analyseResult, setAnalyseResult] = useState<RouterOutputs["import"]["analyseInitialOldmarrImport"] | null>(
null,
);
if (!file) {
return (
<InitStepCard>
<ImportDropZone
loading={isPending}
updateFile={(file) => {
const formData = new FormData();
formData.append("file", file);
mutate(formData, {
onSuccess: (result) => {
startTransition(() => {
setAnalyseResult(result);
setFile(file);
});
},
onError: (error) => {
console.error(error);
},
});
}}
/>
</InitStepCard>
);
}
return (
<Stack w="100%" mb="sm">
<FileInfoCard file={file} onRemove={() => setFile(null)} />
{analyseResult !== null && <InitialOldmarrImport file={file} analyseResult={analyseResult} />}
</Stack>
);
};
@@ -1,209 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
Avatar,
Badge,
Card,
Collapse,
Group,
Loader,
Paper,
ScrollArea,
SimpleGrid,
Stack,
Text,
ThemeIcon,
UnstyledButton,
} from "@mantine/core";
import { IconBrandDocker, IconCheck, IconChevronDown } from "@tabler/icons-react";
import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName } from "@homarr/definitions";
import { useScopedI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "@homarr/ui";
export interface DiscoveredIntegration {
containerId: string;
containerName: string;
kind: IntegrationKind;
suggestedUrl: string;
publishedPort: number | null;
iconUrl: string | null;
}
export interface DiscoveredApp {
containerId: string;
containerName: string;
suggestedUrl: string;
publishedPort: number | null;
iconUrl: string | null;
}
interface DockerDiscoveryIndicatorProps {
integrations: DiscoveredIntegration[];
apps: DiscoveredApp[];
selectedAppIds: Set<string>;
onToggleApp: (containerId: string) => void;
isLoading: boolean;
}
const APP_CARD_HEIGHT = 80;
export const DockerDiscoveryIndicator = ({
integrations,
apps,
selectedAppIds,
onToggleApp,
isLoading,
}: DockerDiscoveryIndicatorProps) => {
const hasApps = apps.length > 0;
const [expanded, setExpanded] = useState(false);
const expandedOnce = useRef(false);
useEffect(() => {
if ((hasApps || integrations.length > 0) && !expandedOnce.current) {
expandedOnce.current = true;
setExpanded(true);
}
}, [hasApps, integrations.length]);
const t = useScopedI18n("init.step.integrations.docker");
const totalCount = integrations.length + apps.length;
if (!isLoading && totalCount === 0) return null;
const selectedAppCount = apps.filter((app) => selectedAppIds.has(app.containerId)).length;
if (isLoading) {
return (
<Paper
withBorder
p="sm"
radius="md"
style={{
borderColor: "var(--mantine-color-blue-4)",
background: "var(--mantine-color-blue-light)",
}}
>
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color="blue" size="lg" radius="md">
<IconBrandDocker size={20} />
</ThemeIcon>
<Stack gap={0} style={{ flex: 1 }}>
<Text size="sm" fw={600}>
{t("scanning")}
</Text>
<Text size="xs" c="dimmed">
{t("scanningDescription")}
</Text>
</Stack>
<Loader size="sm" color="blue" type="dots" />
</Group>
</Paper>
);
}
return (
<Paper
withBorder
p="sm"
radius="md"
style={{
borderColor: "var(--mantine-color-blue-4)",
background: "var(--mantine-color-blue-light)",
}}
>
<UnstyledButton onClick={() => setExpanded((prev) => !prev)} w="100%">
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color="blue" size="lg" radius="md">
<IconBrandDocker size={20} />
</ThemeIcon>
<Stack gap={0}>
<Text size="sm" fw={600}>
{t("detected", { count: String(totalCount) })}
</Text>
<Text size="xs" c="dimmed">
{t("detectedBreakdown", {
integrations: String(integrations.length),
apps: String(apps.length),
})}
</Text>
</Stack>
</Group>
<IconChevronDown
size={16}
color="var(--mantine-color-dimmed)"
style={{
transform: expanded ? "rotate(180deg)" : undefined,
transition: "transform 200ms ease",
}}
/>
</Group>
</UnstyledButton>
<Collapse expanded={expanded}>
<Stack gap="xs" mt="sm">
{integrations.length > 0 && (
<Stack gap={4}>
{integrations.map((integration) => (
<Group key={integration.containerId} gap="xs" wrap="nowrap" py={2}>
<IntegrationAvatar kind={integration.kind} size="xs" />
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
{integration.containerName}
</Text>
<Badge size="xs" variant="light" color="blue">
{getIntegrationName(integration.kind)}
</Badge>
</Group>
))}
</Stack>
)}
{hasApps && (
<Stack gap="xs">
<Text size="xs" fw={500}>
{t("appSectionLabel", { count: String(selectedAppCount) })}
</Text>
<ScrollArea.Autosize mah="30vh">
<SimpleGrid cols={{ base: 3, xs: 4, sm: 5 }} spacing="xs">
{apps.map((app) => {
const isSelected = selectedAppIds.has(app.containerId);
return (
<Card
key={app.containerId}
h={APP_CARD_HEIGHT}
p="xs"
withBorder
style={{
cursor: "pointer",
borderColor: isSelected ? "var(--mantine-color-blue-6)" : undefined,
borderWidth: isSelected ? 2 : undefined,
opacity: isSelected ? 1 : 0.6,
}}
onClick={() => onToggleApp(app.containerId)}
>
<Stack justify="space-between" h="100%" gap={4} align="center">
<Group justify="space-between" w="100%" wrap="nowrap">
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
{app.containerName}
</Text>
{isSelected && <IconCheck size={14} color="var(--mantine-color-blue-6)" />}
</Group>
<Avatar size="sm" radius="sm" src={app.iconUrl} styles={{ image: { objectFit: "contain" } }}>
{app.containerName.at(0)?.toUpperCase()}
</Avatar>
</Stack>
</Card>
);
})}
</SimpleGrid>
</ScrollArea.Autosize>
</Stack>
)}
</Stack>
</Collapse>
</Paper>
);
};
@@ -1,483 +0,0 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
ActionIcon,
Alert,
Avatar,
Button,
Card,
Center,
Collapse,
Group,
Loader,
Paper,
Progress,
SegmentedControl,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import {
IconAlertTriangle,
IconArrowRight,
IconBrandDocker,
IconCheck,
IconChevronDown,
IconSettings,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import type { IntegrationKind, UrlTemplateMode } from "@homarr/definitions";
import { buildAppUrl, buildIntegrationUrl, getIntegrationName } from "@homarr/definitions";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "@homarr/ui";
import { NewIntegrationForm } from "~/app/[locale]/manage/integrations/new/_integration-new-form";
import { IntegrationMultiSelectGrid } from "~/components/integration/integration-multi-select-grid";
import { InitStepCard } from "../../_components/init-step-card";
type Phase = "select" | "configure" | "done";
interface IntegrationDraft {
kind: IntegrationKind;
initialUrl: string;
initialName?: string;
dockerPort?: number | null;
source: "manual" | "docker";
}
export const InitIntegrations = () => {
const [phase, setPhase] = useState<Phase>("select");
const [drafts, setDrafts] = useState<Map<IntegrationKind, IntegrationDraft>>(new Map());
const [selectedKinds, setSelectedKinds] = useState<IntegrationKind[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [baseHost, setBaseHost] = useState("");
const [urlMode, setUrlMode] = useState<UrlTemplateMode>("subdomain");
const t = useScopedI18n("init.step.integrations");
const router = useRouter();
const { mutateAsync: setupIntegrations } = clientApi.onboard.setupIntegrations.useMutation();
const { mutateAsync: createApps } = clientApi.onboard.createAppsFromDiscovery.useMutation();
const { data: dockerData, isPending: isDockerLoading } = clientApi.onboard.discoverDockerServices.useQuery(
undefined,
{
retry: false,
refetchOnWindowFocus: false,
},
);
const dockerIntegrations = dockerData?.status === "success" ? dockerData.integrations : [];
const dockerApps = dockerData?.status === "success" ? dockerData.apps : [];
const totalDetected = dockerIntegrations.length + dockerApps.length;
const detectedKinds = useMemo(() => new Set(dockerIntegrations.map((i) => i.kind)), [dockerIntegrations]);
const [selectedAppIds, setSelectedAppIds] = useState<Set<string>>(new Set());
const [appsExpanded, setAppsExpanded] = useState(false);
const dockerApplied = useRef(false);
useEffect(() => {
if (dockerData?.status !== "success" || dockerApplied.current) return;
dockerApplied.current = true;
const newDrafts = new Map(drafts);
const newKinds = new Set(selectedKinds);
for (const item of dockerData.integrations) {
if (!newDrafts.has(item.kind)) {
newDrafts.set(item.kind, {
kind: item.kind,
initialUrl: item.suggestedUrl,
initialName: item.containerName,
dockerPort: item.publishedPort,
source: "docker",
});
}
newKinds.add(item.kind);
}
setDrafts(newDrafts);
setSelectedKinds(Array.from(newKinds));
setSelectedAppIds(new Set(dockerData.apps.map((app) => app.containerId)));
if (dockerData.apps.length > 0) {
setAppsExpanded(true);
}
}, [dockerData]);
const handleSelectionChange = useCallback((kinds: IntegrationKind[]) => {
setSelectedKinds(kinds);
}, []);
const handleToggleApp = useCallback((containerId: string) => {
setSelectedAppIds((prev) => {
const next = new Set(prev);
if (next.has(containerId)) {
next.delete(containerId);
} else {
next.add(containerId);
}
return next;
});
}, []);
const handleContinueToConfig = () => {
const newDrafts = new Map(drafts);
for (const kind of selectedKinds) {
const existing = newDrafts.get(kind);
if (baseHost) {
newDrafts.set(kind, {
kind,
initialUrl: buildIntegrationUrl(kind, baseHost, urlMode, existing?.dockerPort ?? undefined),
initialName: existing?.initialName,
dockerPort: existing?.dockerPort,
source: existing?.source ?? "manual",
});
} else if (!existing) {
newDrafts.set(kind, { kind, initialUrl: "", source: "manual" });
}
}
setDrafts(newDrafts);
setCurrentIndex(0);
setPhase("configure");
};
const finishSetupAsync = async () => {
setPhase("done");
try {
const appsToCreate = dockerApps.filter((app) => selectedAppIds.has(app.containerId));
if (appsToCreate.length > 0) {
await createApps(
appsToCreate.map((app) => {
const href = baseHost
? buildAppUrl(app.containerName, baseHost, urlMode, app.publishedPort ?? undefined)
: app.suggestedUrl;
return {
name: app.containerName,
href: href || null,
iconUrl: app.iconUrl,
};
}),
);
}
await setupIntegrations();
router.refresh();
} catch {
showErrorNotification({
title: t("configure.error.title"),
message: t("configure.error.message"),
});
setPhase("select");
}
};
const advanceOrFinish = () => {
if (currentIndex + 1 >= selectedKinds.length) {
void finishSetupAsync();
} else {
setCurrentIndex((i) => i + 1);
}
};
const backToSelect = () => {
setPhase("select");
setCurrentIndex(0);
};
if (phase === "done") {
return (
<InitStepCard>
<Center py="xl">
<Stack align="center" gap="md">
<Loader size="lg" />
<Text>{t("configure.finishing")}</Text>
</Stack>
</Center>
</InitStepCard>
);
}
if (phase === "configure") {
return (
<ConfigurePhase
selectedKinds={selectedKinds}
currentIndex={currentIndex}
drafts={drafts}
baseHost={baseHost}
setBaseHost={setBaseHost}
urlMode={urlMode}
setUrlMode={setUrlMode}
onSuccess={advanceOrFinish}
onCancel={backToSelect}
onSkip={advanceOrFinish}
/>
);
}
const selectedAppCount = dockerApps.filter((app) => selectedAppIds.has(app.containerId)).length;
return (
<InitStepCard>
<Stack>
<Text>{t("description")}</Text>
<Alert variant="outline" color="orange" icon={<IconAlertTriangle />} title={t("urlTemplate.label")}>
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("urlTemplate.warning")}
</Text>
<Group gap="sm" grow>
<TextInput
placeholder={t("urlTemplate.placeholder")}
value={baseHost}
onChange={(e) => setBaseHost(e.currentTarget.value)}
data-autofocus
/>
<SegmentedControl
value={urlMode}
onChange={(value) => setUrlMode(value as UrlTemplateMode)}
data={[
{ label: t("urlTemplate.mode.subdomain"), value: "subdomain" },
{ label: t("urlTemplate.mode.hostPort"), value: "hostPort" },
]}
style={{ flex: "0 0 auto" }}
/>
</Group>
{baseHost && (
<Text size="xs" c="dimmed">
{t("urlTemplate.preview", { example: buildIntegrationUrl("sonarr", baseHost, urlMode) })}
</Text>
)}
</Stack>
</Alert>
{isDockerLoading && (
<Paper withBorder p="sm" radius="md">
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color="blue" size="lg" radius="md">
<IconBrandDocker size={20} />
</ThemeIcon>
<Text size="sm" fw={500}>
{t("docker.scanning")}
</Text>
<Loader size="sm" color="blue" type="dots" />
</Group>
</Paper>
)}
{!isDockerLoading && totalDetected > 0 && (
<Paper withBorder p="sm" radius="md" style={{ borderColor: "var(--mantine-color-teal-4)" }}>
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color="teal" size="lg" radius="md">
<IconBrandDocker size={20} />
</ThemeIcon>
<Text size="sm">
{t("docker.summary", {
apps: String(totalDetected),
integrations: String(dockerIntegrations.length),
})}
</Text>
</Group>
</Paper>
)}
<IntegrationMultiSelectGrid
selectedKinds={selectedKinds}
onSelectionChange={handleSelectionChange}
detectedKinds={detectedKinds}
onboarding
/>
{dockerApps.length > 0 && (
<Paper withBorder p="sm" radius="md">
<Stack gap="xs">
<Group
justify="space-between"
wrap="nowrap"
style={{ cursor: "pointer" }}
onClick={() => setAppsExpanded((prev) => !prev)}
>
<Text size="sm" fw={500}>
{t("docker.appSectionLabel", { count: String(selectedAppCount) })}
</Text>
<IconChevronDown
size={16}
color="var(--mantine-color-dimmed)"
style={{
transform: appsExpanded ? "rotate(180deg)" : undefined,
transition: "transform 200ms ease",
}}
/>
</Group>
<Collapse expanded={appsExpanded}>
<SimpleGrid cols={{ base: 3, xs: 4, sm: 5 }} spacing="xs">
{dockerApps.map((app) => {
const isSelected = selectedAppIds.has(app.containerId);
return (
<Card
key={app.containerId}
h={80}
p="xs"
withBorder
style={{
cursor: "pointer",
borderColor: isSelected ? "var(--mantine-color-blue-6)" : undefined,
borderWidth: isSelected ? 2 : undefined,
opacity: isSelected ? 1 : 0.6,
}}
onClick={() => handleToggleApp(app.containerId)}
>
<Stack justify="space-between" h="100%" gap={4} align="center">
<Group justify="space-between" w="100%" wrap="nowrap">
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
{app.containerName}
</Text>
{isSelected && <IconCheck size={14} color="var(--mantine-color-blue-6)" />}
</Group>
<Avatar size="sm" radius="sm" src={app.iconUrl} styles={{ image: { objectFit: "contain" } }}>
{app.containerName.at(0)?.toUpperCase()}
</Avatar>
</Stack>
</Card>
);
})}
</SimpleGrid>
</Collapse>
</Stack>
</Paper>
)}
<Group justify="space-between">
<Button variant="subtle" onClick={() => void finishSetupAsync()}>
{t("action.skip")}
</Button>
<Button
onClick={handleContinueToConfig}
disabled={selectedKinds.length === 0 || undefined}
rightSection={<IconArrowRight size={16} stroke={1.5} />}
suppressHydrationWarning
>
{t("action.continue")} {selectedKinds.length > 0 && `(${selectedKinds.length})`}
</Button>
</Group>
</Stack>
</InitStepCard>
);
};
interface ConfigurePhaseProps {
selectedKinds: IntegrationKind[];
currentIndex: number;
drafts: Map<IntegrationKind, IntegrationDraft>;
baseHost: string;
setBaseHost: (value: string) => void;
urlMode: UrlTemplateMode;
setUrlMode: (value: UrlTemplateMode) => void;
onSuccess: () => void;
onCancel: () => void;
onSkip: () => void;
}
const ConfigurePhase = ({
selectedKinds,
currentIndex,
drafts,
baseHost,
setBaseHost,
urlMode,
setUrlMode,
onSuccess,
onCancel,
onSkip,
}: ConfigurePhaseProps) => {
const t = useScopedI18n("init.step.integrations");
const [helpersOpen, { toggle: toggleHelpers }] = useDisclosure(false);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- currentIndex is always within bounds of selectedKinds
const currentKind = selectedKinds[currentIndex]!;
const draft = drafts.get(currentKind);
const progress = ((currentIndex + 1) / selectedKinds.length) * 100;
const computedUrl = baseHost
? buildIntegrationUrl(currentKind, baseHost, urlMode, draft?.dockerPort ?? undefined)
: draft?.initialUrl;
return (
<InitStepCard>
<Stack>
<Group justify="space-between" align="center">
<Group gap="xs">
<IntegrationAvatar kind={currentKind} size="sm" />
<Text fw={500}>{getIntegrationName(currentKind)}</Text>
</Group>
<Tooltip label={t("configure.helpers.tooltip")}>
<ActionIcon variant={helpersOpen ? "light" : "subtle"} color="gray" onClick={toggleHelpers}>
<IconSettings size={18} stroke={1.5} />
</ActionIcon>
</Tooltip>
</Group>
<Stack gap={4}>
<Progress value={progress} size="sm" />
<Text size="xs" c="dimmed" ta="center">
{t("configure.progress", { current: String(currentIndex + 1), total: String(selectedKinds.length) })}
</Text>
</Stack>
<Collapse expanded={helpersOpen}>
<Stack
gap="xs"
p="xs"
style={{ background: "var(--mantine-color-default-hover)", borderRadius: "var(--mantine-radius-sm)" }}
>
<Text size="xs" c="dimmed">
{t("configure.helpers.description")}
</Text>
<Group gap="sm" grow>
<TextInput
size="xs"
placeholder={t("urlTemplate.placeholder")}
value={baseHost}
onChange={(e) => setBaseHost(e.currentTarget.value)}
/>
<SegmentedControl
size="xs"
value={urlMode}
onChange={(value) => setUrlMode(value as UrlTemplateMode)}
data={[
{ label: t("urlTemplate.mode.subdomain"), value: "subdomain" },
{ label: t("urlTemplate.mode.hostPort"), value: "hostPort" },
]}
style={{ flex: "0 0 auto" }}
/>
</Group>
{baseHost && (
<Text size="xs" c="dimmed">
{t("urlTemplate.preview", {
example: buildIntegrationUrl(currentKind, baseHost, urlMode, draft?.dockerPort ?? undefined),
})}
</Text>
)}
</Stack>
</Collapse>
<NewIntegrationForm
key={currentKind}
kind={currentKind}
initialUrl={computedUrl}
initialName={draft?.initialName}
onSuccess={onSuccess}
onCancel={onCancel}
onSkip={onSkip}
isOnboarding
/>
</Stack>
</InitStepCard>
);
};
@@ -1,129 +0,0 @@
"use client";
import { Button, Group, Stack, Switch, Text } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react";
import type { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import type { CheckboxProps } from "@homarr/form/types";
import { defaultServerSettings } from "@homarr/server-settings";
import type { TranslationObject } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { settingsInitSchema } from "@homarr/validation/settings";
import { InitStepCard } from "../../_components/init-step-card";
export const InitSettings = () => {
const tSection = useScopedI18n("management.page.settings.section");
const t = useI18n();
const { mutateAsync } = clientApi.serverSettings.initSettings.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/init");
},
});
const form = useZodForm(settingsInitSchema, { initialValues: defaultServerSettings });
const handleSubmitAsync = async (values: z.infer<typeof settingsInitSchema>) => {
await mutateAsync(values);
};
return (
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
<Stack w="100%">
<InitStepCard>
<Stack gap="sm">
<Text fw={500}>{tSection("analytics.title")}</Text>
<Stack gap="xs">
<AnalyticsRow kind="general" {...form.getInputProps("analytics.enableGeneral", { type: "checkbox" })} />
</Stack>
</Stack>
</InitStepCard>
<InitStepCard>
<Stack gap="sm">
<Text fw={500}>{tSection("crawlingAndIndexing.title")}</Text>
<Stack gap="xs">
<CrawlingRow
kind="noIndex"
{...form.getInputProps("crawlingAndIndexing.noIndex", { type: "checkbox" })}
/>
<CrawlingRow
kind="noFollow"
{...form.getInputProps("crawlingAndIndexing.noFollow", { type: "checkbox" })}
/>
<CrawlingRow
kind="noTranslate"
{...form.getInputProps("crawlingAndIndexing.noTranslate", { type: "checkbox" })}
/>
<CrawlingRow
kind="noSiteLinksSearchBox"
{...form.getInputProps("crawlingAndIndexing.noSiteLinksSearchBox", { type: "checkbox" })}
/>
</Stack>
</Stack>
</InitStepCard>
<Button type="submit" loading={form.submitting} rightSection={<IconArrowRight size={16} stroke={1.5} />}>
{t("common.action.continue")}
</Button>
</Stack>
</form>
);
};
interface AnalyticsRowProps {
kind: Exclude<keyof TranslationObject["management"]["page"]["settings"]["section"]["analytics"], "title">;
disabled?: boolean;
}
const AnalyticsRow = ({ kind, ...props }: AnalyticsRowProps & CheckboxProps) => {
const tSection = useI18n("management.page.settings.section");
return (
<SettingRow title={tSection(`analytics.${kind}.title`)} text={tSection(`analytics.${kind}.text`)} {...props} />
);
};
interface CrawlingRowProps {
kind: Exclude<
keyof TranslationObject["management"]["page"]["settings"]["section"]["crawlingAndIndexing"],
"title" | "warning"
>;
}
const CrawlingRow = ({ kind, ...inputProps }: CrawlingRowProps & CheckboxProps) => {
const tSection = useI18n("management.page.settings.section");
return (
<SettingRow
title={tSection(`crawlingAndIndexing.${kind}.title`)}
text={tSection(`crawlingAndIndexing.${kind}.text`)}
{...inputProps}
/>
);
};
const SettingRow = ({
title,
text,
disabled,
...inputProps
}: { title: string; text: string; disabled?: boolean } & CheckboxProps) => {
return (
<Group wrap="nowrap" align="center">
<Stack gap={0} style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{title}
</Text>
<Text size="xs" c="gray.5">
{text}
</Text>
</Stack>
<Switch disabled={disabled} {...inputProps} />
</Group>
);
};
@@ -1,37 +0,0 @@
import { Stack, Text } from "@mantine/core";
import { IconFileImport, IconPlayerPlay } from "@tabler/icons-react";
import { getMantineColor } from "@homarr/common";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { getScopedI18n } from "@homarr/translation/server";
import { InitStepCard } from "../../_components/init-step-card";
import { InitSqliteRestore } from "./sqlite-restore";
import { InitStartButton } from "./next-button";
export const InitStart = async () => {
const tStart = await getScopedI18n("init.step.start");
const isSqlite = dbEnv.DRIVER === "better-sqlite3";
return (
<InitStepCard>
<Stack>
<Text>{tStart("description")}</Text>
<InitStartButton
preferredStep={undefined}
icon={<IconPlayerPlay color={getMantineColor("green", 6)} size={16} stroke={1.5} />}
>
{tStart("action.scratch")}
</InitStartButton>
<InitStartButton
preferredStep="import"
icon={<IconFileImport color={getMantineColor("cyan", 6)} size={16} stroke={1.5} />}
>
{tStart("action.importOldmarr")}
</InitStartButton>
{isSqlite && <InitSqliteRestore />}
</Stack>
</InitStepCard>
);
};
@@ -1,28 +0,0 @@
"use client";
import type { PropsWithChildren, ReactNode } from "react";
import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import type { OnboardingStep } from "@homarr/definitions";
interface InitStartButtonProps {
icon: ReactNode;
preferredStep: OnboardingStep | undefined;
}
export const InitStartButton = ({ preferredStep, icon, children }: PropsWithChildren<InitStartButtonProps>) => {
const { mutateAsync } = clientApi.onboard.nextStep.useMutation();
const handleClickAsync = async () => {
await mutateAsync({ preferredStep });
await revalidatePathActionAsync("/init");
};
return (
<Button onClick={handleClickAsync} variant="default" leftSection={icon}>
{children}
</Button>
);
};
@@ -1,39 +0,0 @@
"use client";
import { useState } from "react";
import { Button, Stack, Text } from "@mantine/core";
import { IconDatabaseImport } from "@tabler/icons-react";
import { getMantineColor } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { DatabaseRestoreFlow } from "~/components/backup";
export const InitSqliteRestore = () => {
const t = useScopedI18n("init.step.start.action");
const [expanded, setExpanded] = useState(false);
if (expanded) {
return (
<Stack gap="sm">
<Button variant="subtle" size="xs" onClick={() => setExpanded(false)}>
{t("backToOptions")}
</Button>
<Text size="sm" c="dimmed">
{t("restoreSqliteDescription")}
</Text>
<DatabaseRestoreFlow variant="standalone" />
</Stack>
);
}
return (
<Button
onClick={() => setExpanded(true)}
variant="default"
leftSection={<IconDatabaseImport color={getMantineColor("orange", 6)} size={16} stroke={1.5} />}
>
{t("restoreSqlite")}
</Button>
);
};
@@ -1,75 +0,0 @@
"use client";
import { Button, Stack, TextInput } from "@mantine/core";
import type { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import { signIn } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { UserCreatePasswordFields } from "@homarr/forms-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { userInitSchema } from "@homarr/validation/user";
export const InitUserForm = () => {
const t = useScopedI18n("user");
const tUser = useScopedI18n("init.step.user");
const { mutateAsync, isPending } = clientApi.user.initUser.useMutation();
const form = useZodForm(userInitSchema, {
initialValues: {
username: "",
password: "",
confirmPassword: "",
},
});
const handleSubmitAsync = async (values: FormType) => {
await mutateAsync(values, {
onSuccess() {
showSuccessNotification({
title: tUser("notification.success.title"),
message: tUser("notification.success.message"),
});
void signIn("credentials", {
name: values.username,
password: values.password,
redirect: false,
}).then(async () => {
await revalidatePathActionAsync("/init");
});
},
onError: (error) => {
showErrorNotification({
title: tUser("notification.error.title"),
message: error.message,
});
},
});
};
return (
<Stack gap="xl">
<form
onSubmit={form.onSubmit(
(values) => void handleSubmitAsync(values),
(err) => console.log(err),
)}
>
<Stack gap="lg">
<TextInput label={t("field.username.label")} {...form.getInputProps("username")} />
<UserCreatePasswordFields
passwordInputProps={form.getInputProps("password")}
confirmPasswordInputProps={form.getInputProps("confirmPassword")}
/>
<Button type="submit" fullWidth loading={isPending}>
{t("action.create")}
</Button>
</Stack>
</form>
</Stack>
);
};
type FormType = z.infer<typeof userInitSchema>;
@@ -1,10 +0,0 @@
import { InitStepCard } from "../../_components/init-step-card";
import { InitUserForm } from "./init-user-form";
export const InitUser = () => {
return (
<InitStepCard>
<InitUserForm />
</InitStepCard>
);
};
+56 -58
View File
@@ -1,66 +1,64 @@
import type { JSX } from "react";
import { Box, Center, Stack, Text, Title } from "@mantine/core";
import { headers } from "next/headers";
import type { MaybePromise } from "@homarr/common/types";
import { db } from "@homarr/db";
import type { OnboardingStep } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { normalizeOnboardingStep } from "@homarr/api/onboarding-step";
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { extractBaseUrlFromHeaders } from "@homarr/common";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { db, eq } from "@homarr/db";
import { groups } from "@homarr/db/schema";
import { everyoneGroup } from "@homarr/definitions";
import { env as dockerEnv } from "@homarr/docker/env";
import { OnboardingStudio } from "@homarr/onboarding";
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
import { CurrentColorSchemeCombobox } from "~/components/color-scheme/current-color-scheme-combobox";
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { onboardingContentWidth, onboardingMaxWidth } from "./_constants";
import { BackToStart } from "./_steps/back";
import { InitFinish } from "./_steps/finish/init-finish";
import { InitGroup } from "./_steps/group/init-group";
import { InitImport } from "./_steps/import/init-import";
import { InitIntegrations } from "./_steps/integrations/init-integrations";
import { InitSettings } from "./_steps/settings/init-settings";
import { InitStart } from "./_steps/start/init-start";
import { InitUser } from "./_steps/user/init-user";
const stepComponents: Record<OnboardingStep, null | (() => MaybePromise<JSX.Element>)> = {
start: InitStart,
import: InitImport,
user: InitUser,
group: InitGroup,
settings: InitSettings,
integrations: InitIntegrations,
finish: InitFinish,
};
const getCurrentOnboardingStepAsync = async () => {
const value = await db.query.onboarding.findFirst();
if (!value) return { current: "start" as const, previous: null };
return { current: value.step, previous: value.previousStep };
};
import { DatabaseRestoreFlow } from "~/components/backup";
import { env } from "~/env";
export default async function InitPage() {
const t = await getScopedI18n("init.step");
const currentStep = await getCurrentOnboardingStepAsync();
const CurrentComponent = stepComponents[currentStep.current];
const [state, session, requestHeaders, defaultGroup, firstUser, availableBoards] = await Promise.all([
db.query.onboarding.findFirst(),
auth(),
headers(),
db.query.groups.findFirst({
where: eq(groups.name, everyoneGroup),
columns: { homeBoardId: true },
}),
db.query.users.findFirst({ columns: { id: true } }),
db.query.boards.findMany({ columns: { id: true, name: true } }),
]);
const homeBoardId = defaultGroup?.homeBoardId;
const homeBoard = availableBoards.find((board) => board.id === homeBoardId);
const initialBoard = homeBoard ?? (availableBoards.length === 1 ? availableBoards[0] : null);
const currentStep = normalizeOnboardingStep(state?.step);
const databaseDriver =
dbEnv.DRIVER === "better-sqlite3" ? "sqlite" : dbEnv.DRIVER === "mysql2" ? "mysql" : "postgresql";
const { workshopApiUrl, workshopWebUrl } = resolveHomarrUrlConfig({
homarrWebsiteUrl: env.HOMARR_WEBSITE_URL,
workshopApiUrl: env.WORKSHOP_API_URL,
workshopWebUrl: env.WORKSHOP_WEB_URL,
});
const baseUrl = extractBaseUrlFromHeaders(requestHeaders);
return (
<Box mih="100dvh">
<Center>
<Stack align="center" mt="xl" w={onboardingContentWidth} maw={onboardingMaxWidth}>
<HomarrLogoWithTitle size="lg" />
<Stack gap={6} align="center">
<Title order={3} fw={400} ta="center">
{t(`${currentStep.current}.title`)}
</Title>
<Text size="sm" c="gray.5" ta="center">
{t(`${currentStep.current}.subtitle`)}
</Text>
</Stack>
<CurrentLanguageCombobox width="100%" />
<CurrentColorSchemeCombobox w="100%" />
{CurrentComponent && <CurrentComponent />}
{currentStep.previous === "start" && <BackToStart />}
</Stack>
</Center>
</Box>
<OnboardingStudio
environment={{
currentStep,
databaseDriver,
credentialsEnabled: isProviderEnabled("credentials"),
externalAuthEnabled: isProviderEnabled("ldap") || isProviderEnabled("oidc"),
dockerConfigured: dockerEnv.ENABLE_DOCKER,
kubernetesConfigured: dockerEnv.ENABLE_KUBERNETES,
workshopEnabled: true,
workshopApiUrl,
workshopUrl: workshopWebUrl,
mcpEndpoint: `${baseUrl}/api/mcp`,
canConfigurePrivileged: session?.user.permissions.includes("admin") ?? false,
hasUsers: Boolean(firstUser),
initialBoard: initialBoard ?? null,
availableBoards,
}}
sqliteRestore={databaseDriver === "sqlite" ? <DatabaseRestoreFlow variant="standalone" /> : undefined}
/>
);
}
@@ -17,6 +17,7 @@ interface AppDeleteButtonProps {
export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
const t = useScopedI18n("app.page.delete");
const tList = useScopedI18n("app.page.list.action");
const { openConfirmModal } = useConfirmModal();
const { mutate, isPending } = clientApi.app.delete.useMutation();
@@ -50,7 +51,14 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
}, [app, mutate, t, openConfirmModal]);
return (
<ActionIcon loading={isPending} variant="subtle" color="red" onClick={onClick} aria-label={t("title")}>
<ActionIcon
loading={isPending}
variant="subtle"
color="red"
size={44}
onClick={onClick}
aria-label={tList("delete", { name: app.name })}
>
<IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon>
);
@@ -16,10 +16,7 @@ export default async function AppEditPage(props: AppEditPageProps) {
const params = await props.params;
const session = await auth();
if (
!session?.user.permissions.includes("board-modify-all") ||
!session?.user.permissions.includes("app-modify-all")
) {
if (!session?.user.permissions.includes("app-modify-all")) {
notFound();
}
const app = await api.app.byId({ id: params.id });
@@ -10,7 +10,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
export default async function AppNewPage() {
const session = await auth();
if (!session?.user.permissions.includes("board-modify-all") || !session?.user.permissions.includes("app-create")) {
if (!session?.user.permissions.includes("app-create")) {
notFound();
}
@@ -1,18 +1,19 @@
import { Fragment } from "react";
import { notFound, redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core";
import { redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Text } from "@mantine/core";
import { IconBox, IconPencil } from "@tabler/icons-react";
import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getSafeAppHref } from "@homarr/common";
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { getScopedI18n } from "@homarr/translation/server";
import { Link, SearchInput, TablePagination } from "@homarr/ui";
import { TourTarget } from "~/components/layout/header/tour-target";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { ManageCollectionItem, ManageCollectionPage } from "~/components/manage/manage-collection";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { NoResults } from "~/components/no-results";
import { AppDeleteButton } from "./_app-delete-button";
@@ -29,24 +30,38 @@ interface AppsPageProps {
export default async function AppsPage(props: AppsPageProps) {
const session = await auth();
if (!session) {
redirect("/auth/login");
}
if (!session.user.permissions.includes("board-modify-all")) {
notFound();
}
if (!session) redirect("/auth/login");
const searchParams = searchParamsSchema.parse(await props.searchParams);
const { items: apps, totalCount } = await api.app.getPaginated(searchParams);
const t = await getScopedI18n("app");
const canCreate = session.user.permissions.includes("app-create");
const canModify = session.user.permissions.includes("app-modify-all");
const canDelete = session.user.permissions.includes("app-full-all");
const hasSearch = Boolean(searchParams.search?.trim());
return (
<ManagePageLayout
const emptyState = hasSearch ? (
<NoResults
icon={IconBox}
title={t("page.list.noResults.filteredTitle")}
description={t("page.list.noResults.filteredDescription", { search: searchParams.search ?? "" })}
action={{ label: t("page.list.noResults.clearSearch"), href: "/manage/apps" }}
/>
) : (
<NoResults
icon={IconBox}
title={t("page.list.noResults.title")}
description={t("page.list.noResults.description")}
action={{ label: t("page.list.noResults.action"), href: "/manage/apps/new", hidden: !canCreate }}
/>
);
const page = (
<ManageCollectionPage
title={t("page.list.title")}
ariaLabel={t("page.list.ariaLabel")}
itemCount={apps.length}
emptyState={emptyState}
primaryAction={
canCreate ? (
<TourTarget id="manage-apps-create">
@@ -56,101 +71,94 @@ export default async function AppsPage(props: AppsPageProps) {
</TourTarget>
) : undefined
}
toolbar={<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand />}
footer={<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />}
toolbar={
<SearchInput
placeholder={`${t("search")}...`}
ariaLabel={t("search")}
defaultValue={searchParams.search}
flexExpand
/>
}
footer={
totalCount > searchParams.pageSize ? (
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
) : undefined
}
floatingPrimaryAction={canCreate}
>
{apps.length === 0 && <AppNoResults />}
{apps.length > 0 && (
<TourTarget id="manage-apps-list">
<Stack gap="sm">
{apps.map((app) => (
<AppCard key={app.id} app={app} />
))}
</Stack>
</TourTarget>
)}
</ManagePageLayout>
{apps.map((app) => (
<AppItem key={app.id} app={app} canModify={canModify} canDelete={canDelete} />
))}
</ManageCollectionPage>
);
return apps.length > 0 ? <TourTarget id="manage-apps-list">{page}</TourTarget> : page;
}
interface AppCardProps {
app: RouterOutputs["app"]["all"][number];
interface AppItemProps {
app: RouterOutputs["app"]["getPaginated"]["items"][number];
canModify: boolean;
canDelete: boolean;
}
const AppCard = async ({ app }: AppCardProps) => {
const AppItem = async ({ app, canModify, canDelete }: AppItemProps) => {
const t = await getScopedI18n("app");
const session = await auth();
const descriptionLines = app.description?.split("\n");
const safeHref = getSafeAppHref(app.href);
return (
<Card>
<Group justify="space-between" wrap="nowrap">
<Group align="top" justify="start" wrap="nowrap" style={{ flex: "1" }}>
<Avatar
<ManageCollectionItem
leading={<Avatar size={36} src={app.iconUrl} radius="sm" styles={{ image: { objectFit: "contain" } }} alt="" />}
title={
<Text component="span" fw={600} lineClamp={1}>
{app.name}
</Text>
}
description={
descriptionLines ? (
<Text size="sm" c="dimmed" lineClamp={2}>
{descriptionLines.map((line, index) => (
<Fragment key={index}>
{line}
{index < descriptionLines.length - 1 && <br />}
</Fragment>
))}
</Text>
) : undefined
}
metadata={
safeHref ? (
<Anchor
href={safeHref}
target="_blank"
rel="noreferrer"
lineClamp={1}
size="sm"
src={app.iconUrl}
radius={0}
styles={{
image: {
objectFit: "contain",
},
}}
/>
<Stack gap={0} style={{ flex: "1" }}>
<Text fw={500} lineClamp={1}>
{app.name}
</Text>
{app.description && (
<Text size="sm" c="gray.6" lineClamp={4}>
{app.description.split("\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
</Text>
)}
{app.href && (
<Anchor href={app.href} lineClamp={1} size="sm" style={{ wordBreak: "break-all" }}>
{app.href}
</Anchor>
)}
</Stack>
</Group>
<Group>
style={{ wordBreak: "break-all" }}
>
{app.href}
</Anchor>
) : undefined
}
actions={
canModify || canDelete ? (
<ActionIconGroup>
{session?.user.permissions.includes("app-modify-all") && (
{canModify && (
<ActionIcon
component={Link}
href={`/manage/apps/edit/${app.id}`}
variant="subtle"
color="gray"
aria-label={t("page.edit.title")}
size={44}
aria-label={t("page.list.action.edit", { name: app.name })}
>
<IconPencil size={16} stroke={1.5} />
<IconPencil size={18} stroke={1.5} />
</ActionIcon>
)}
{session?.user.permissions.includes("app-full-all") && <AppDeleteButton app={app} />}
{canDelete && <AppDeleteButton app={app} />}
</ActionIconGroup>
</Group>
</Group>
</Card>
);
};
const AppNoResults = async () => {
const t = await getI18n();
const session = await auth();
return (
<NoResults
icon={IconBox}
title={t("app.page.list.noResults.title")}
action={{
label: t("app.page.list.noResults.action"),
href: "/manage/apps/new",
hidden: !session?.user.permissions.includes("app-create"),
}}
) : undefined
}
/>
);
};
@@ -1,38 +1,23 @@
"use client";
import { Button, Menu } from "@mantine/core";
import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react";
import { Button } from "@mantine/core";
import { IconCategoryPlus } from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals";
import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection";
import { AddBoardModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
export const CreateBoardButton = () => {
const t = useI18n();
const { openModal: openAddModal } = useModalAction(AddBoardModal);
const { openModal: openImportModal } = useModalAction(ImportBoardModal);
const { openModal } = useModalAction(AddBoardModal);
return (
<ManageMobilePrimaryAction>
<Button.Group>
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openAddModal}>
{t("management.page.board.action.new.label")}
</Button>
<Menu position="bottom-end">
<Menu.Target>
<Button px="xs" ms={1}>
<IconChevronDown size="1rem" />
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={openImportModal} leftSection={<IconFileImport size="1rem" />}>
{t("board.action.oldImport.label")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Button.Group>
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openModal}>
{t("management.page.board.action.new.label")}
</Button>
</ManageMobilePrimaryAction>
);
};
@@ -24,10 +24,11 @@ import {
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { getScopedI18n } from "@homarr/translation/server";
import { Link, UserAvatar } from "@homarr/ui";
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
import { BoardLayoutThumbnail } from "~/components/board/board-layout-thumbnail";
import { TourTarget } from "~/components/layout/header/tour-target";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { NoResults } from "~/components/no-results";
@@ -37,7 +38,7 @@ import { CreateBoardButton } from "./_components/create-board-button";
export default async function ManageBoardsPage() {
const t = await getScopedI18n("management.page.board");
const session = await auth();
const boards = await api.board.getAllBoards();
const boards = await api.board.getManageOverview();
const canCreateBoards = session?.user.permissions.includes("board-create");
return (
@@ -52,13 +53,31 @@ export default async function ManageBoardsPage() {
}
floatingPrimaryAction={canCreateBoards}
>
{boards.length === 0 && <NoResults icon={IconLayoutDashboard} title={t("noResults.title")} />}
{boards.length === 0 && (
<NoResults icon={IconLayoutDashboard} title={t("noResults.title")} description={t("noResults.description")} />
)}
{boards.length > 0 && (
<TourTarget id="manage-boards-list">
<Grid>
{boards.map((board) => (
<GridCol span={{ base: 12, md: 6 }} key={board.id}>
<BoardCard board={board} />
<BoardCard
board={board}
isMenuVisible={constructBoardPermissions(board, session).hasChangeAccess}
labels={{
visibility: t(`visibility.${board.isPublic ? "public" : "private"}`),
preview: t("preview.label", {
name: board.name,
count: String(board.preview?.items.length ?? 0),
}),
home: t("action.setHomeBoard.badge.label"),
homeTooltip: t("action.setHomeBoard.badge.tooltip"),
mobileHome: t("action.setMobileHomeBoard.badge.label"),
mobileHomeTooltip: t("action.setMobileHomeBoard.badge.tooltip"),
open: t("action.open.label"),
settings: t("action.settings.label"),
}}
/>
</GridCol>
))}
</Grid>
@@ -69,48 +88,60 @@ export default async function ManageBoardsPage() {
}
interface BoardCardProps {
board: RouterOutputs["board"]["getAllBoards"][number];
board: RouterOutputs["board"]["getManageOverview"][number];
isMenuVisible: boolean;
labels: {
visibility: string;
preview: string;
home: string;
homeTooltip: string;
mobileHome: string;
mobileHomeTooltip: string;
open: string;
settings: string;
};
}
const BoardCard = async ({ board }: BoardCardProps) => {
const t = await getScopedI18n("management.page.board");
const { hasChangeAccess: isMenuVisible } = await getBoardPermissionsAsync(board);
const visibility = board.isPublic ? "public" : "private";
const BoardCard = ({ board, isMenuVisible, labels }: BoardCardProps) => {
const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
return (
<Card>
<Card padding={0} withBorder>
<CardSection withBorder>
<BoardLayoutThumbnail preview={board.preview} label={labels.preview} />
</CardSection>
<CardSection p="sm" withBorder>
<Group justify="space-between" align="center">
<Group gap="sm">
<Tooltip label={t(`visibility.${visibility}`)}>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="xs">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0, flex: "1 1 12rem" }}>
<Tooltip label={labels.visibility}>
<VisibilityIcon size={20} stroke={1.5} />
</Tooltip>
<Text fw={700}>{board.name}</Text>
<Text fw={700} truncate>
{board.name}
</Text>
</Group>
<Group>
<Group gap="xs" justify="flex-end" wrap="wrap" style={{ marginInlineStart: "auto" }}>
{board.isHome && (
<Tooltip label={t("action.setHomeBoard.badge.tooltip")}>
<Tooltip label={labels.homeTooltip}>
<Badge tt="none" color="yellow" variant="light" leftSection={<IconHomeFilled size=".7rem" />}>
{t("action.setHomeBoard.badge.label")}
{labels.home}
</Badge>
</Tooltip>
)}
{board.isMobileHome && (
<Tooltip label={t("action.setMobileHomeBoard.badge.tooltip")}>
<Tooltip label={labels.mobileHomeTooltip}>
<Badge tt="none" color="yellow" variant="light" leftSection={<IconDeviceMobile size=".7rem" />}>
{t("action.setMobileHomeBoard.badge.label")}
{labels.mobileHome}
</Badge>
</Tooltip>
)}
{board.creator && (
<Group gap="xs">
<Tooltip label={board.creator.name ?? board.creator.email}>
<UserAvatar user={board.creator} size="sm" />
<Text>{board.creator.name}</Text>
</Group>
</Tooltip>
)}
</Group>
</Group>
@@ -123,9 +154,10 @@ const BoardCard = async ({ board }: BoardCardProps) => {
component={Link}
href={`/boards/${board.name}`}
variant="default"
h={44}
fullWidth
>
{t("action.open.label")}
{labels.open}
</Button>
{isMenuVisible && (
<Menu position="bottom-end">
@@ -133,8 +165,8 @@ const BoardCard = async ({ board }: BoardCardProps) => {
<ActionIcon
style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }}
variant="default"
size="lg"
aria-label={t("action.settings.label")}
size={44}
aria-label={labels.settings}
>
<IconDotsVertical size={16} stroke={1.5} />
</ActionIcon>
@@ -1,6 +1,5 @@
"use client";
import { useRouter } from "next/navigation";
import { ActionIcon } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
@@ -11,13 +10,12 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
import { useScopedI18n } from "@homarr/translation/client";
interface DeleteIntegrationActionButtonProps {
count: number;
integration: { id: string; name: string };
}
export const DeleteIntegrationActionButton = ({ count, integration }: DeleteIntegrationActionButtonProps) => {
export const DeleteIntegrationActionButton = ({ integration }: DeleteIntegrationActionButtonProps) => {
const t = useScopedI18n("integration.page.delete");
const router = useRouter();
const tList = useScopedI18n("integration.page.list.action");
const { openConfirmModal } = useConfirmModal();
const utils = clientApi.useUtils();
const { mutateAsync, isPending } = clientApi.integration.delete.useMutation();
@@ -27,6 +25,7 @@ export const DeleteIntegrationActionButton = ({ count, integration }: DeleteInte
loading={isPending}
variant="subtle"
color="red"
size={44}
onClick={() => {
openConfirmModal({
title: t("title"),
@@ -40,9 +39,6 @@ export const DeleteIntegrationActionButton = ({ count, integration }: DeleteInte
title: t("notification.success.title"),
message: t("notification.success.message"),
});
if (count === 1) {
router.replace("/manage/integrations");
}
void revalidatePathActionAsync("/manage/integrations");
void utils.integration.invalidate();
},
@@ -57,7 +53,7 @@ export const DeleteIntegrationActionButton = ({ count, integration }: DeleteInte
},
});
}}
aria-label={t("title")}
aria-label={tList("delete", { name: integration.name })}
>
<IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon>
@@ -1,34 +0,0 @@
.root {
border-radius: var(--mantine-radius-lg);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
.item {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
border: 1px solid transparent;
position: relative;
z-index: 0;
transition: background-color 150ms ease;
overflow: hidden;
&[data-first="true"] {
border-radius: var(--mantine-radius-lg) var(--mantine-radius-lg) 0 0;
}
&[data-last="true"] {
border-radius: 0 0 var(--mantine-radius-lg) var(--mantine-radius-lg);
}
&[data-active] {
z-index: 1;
background-color: var(--mantine-color-body);
border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
border-radius: var(--mantine-radius-lg);
}
}
.chevron {
&[data-rotate] {
transform: rotate(180deg);
}
}
@@ -1,66 +1,80 @@
import { Fragment } from "react";
import { redirect } from "next/navigation";
import {
AccordionControl,
AccordionItem,
AccordionPanel,
ActionIcon,
ActionIconGroup,
Anchor,
Divider,
Group,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
} from "@mantine/core";
import { ActionIcon, ActionIconGroup, Anchor, Badge, Text } from "@mantine/core";
import { IconPencil, IconPlugX } from "@tabler/icons-react";
import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { objectEntries } from "@homarr/common";
import type { IntegrationKind } from "@homarr/definitions";
import { getSafeApplicationUrl } from "@homarr/common";
import { getIntegrationName } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui";
import { IntegrationAvatar, Link, SearchInput } from "@homarr/ui";
import { TourTarget } from "~/components/layout/header/tour-target";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { ManageCollectionItem, ManageCollectionPage } from "~/components/manage/manage-collection";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { NoResults } from "~/components/no-results";
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
import { DeleteIntegrationActionButton } from "./_integration-buttons";
import classes from "./page.module.css";
const searchParamsSchema = z.object({ search: z.string().optional() });
interface IntegrationsPageProps {
searchParams: Promise<{
tab?: IntegrationKind;
}>;
searchParams: Promise<z.infer<typeof searchParamsSchema>>;
}
export default async function IntegrationsPage(props: IntegrationsPageProps) {
const searchParams = await props.searchParams;
const session = await auth();
if (!session) redirect("/auth/login");
if (!session) {
redirect("/auth/login");
}
const searchParams = searchParamsSchema.parse(await props.searchParams);
const integrations = await api.integration.all();
const t = await getScopedI18n("integration");
const canCreate = session.user.permissions.includes("integration-create");
const hasGlobalFullAccess = session.user.permissions.includes("integration-full-all");
const query = searchParams.search?.trim().toLocaleLowerCase() ?? "";
const filteredIntegrations = integrations
.filter((integration) => {
if (!query) return true;
return [integration.name, getIntegrationName(integration.kind), integration.url].some((value) =>
value.toLocaleLowerCase().includes(query),
);
})
.toSorted(
(integrationA, integrationB) =>
getIntegrationName(integrationA.kind).localeCompare(getIntegrationName(integrationB.kind)) ||
integrationA.name.localeCompare(integrationB.name),
);
const hasSearch = query.length > 0;
const canCreateIntegrations = session.user.permissions.includes("integration-create");
return (
<ManagePageLayout
const page = (
<ManageCollectionPage
title={t("page.list.title")}
ariaLabel={t("page.list.ariaLabel")}
itemCount={filteredIntegrations.length}
emptyState={
hasSearch ? (
<NoResults
icon={IconPlugX}
title={t("page.list.noResults.filteredTitle")}
description={t("page.list.noResults.filteredDescription", { search: searchParams.search ?? "" })}
action={{ label: t("page.list.noResults.clearSearch"), href: "/manage/integrations" }}
/>
) : (
<NoResults
icon={IconPlugX}
title={t("page.list.noResults.title")}
description={t("page.list.noResults.description")}
action={{
label: t("page.list.noResults.action"),
href: "/manage/integrations/new",
hidden: !canCreate,
}}
/>
)
}
primaryAction={
canCreateIntegrations ? (
canCreate ? (
<TourTarget id="manage-integrations-create">
<MobileAffixButton component={Link} href="/manage/integrations/new">
{t("action.create")}
@@ -68,128 +82,83 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
</TourTarget>
) : undefined
}
floatingPrimaryAction={canCreateIntegrations}
>
<TourTarget id="manage-integrations-list">
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
</TourTarget>
</ManagePageLayout>
);
}
interface IntegrationListProps {
integrations: RouterOutputs["integration"]["all"];
activeTab?: IntegrationKind;
}
const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps) => {
const t = await getScopedI18n("integration");
const session = await auth();
const hasFullAccess = session?.user.permissions.includes("integration-full-all") ?? false;
if (integrations.length === 0) {
return <NoResults icon={IconPlugX} title={t("page.list.noResults.title")} />;
}
const groupedIntegrations = integrations.reduce(
(acc, integration) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!acc[integration.kind]) {
acc[integration.kind] = [];
toolbar={
<SearchInput
placeholder={`${t("page.list.search")}...`}
ariaLabel={t("page.list.search")}
defaultValue={searchParams.search}
flexExpand
/>
}
acc[integration.kind].push(integration);
return acc;
},
{} as Record<IntegrationKind, RouterOutputs["integration"]["all"]>,
floatingPrimaryAction={canCreate}
>
{filteredIntegrations.map((integration) => (
<IntegrationItem
key={integration.id}
integration={integration}
canManage={hasGlobalFullAccess || integration.permissions.hasFullAccess}
/>
))}
</ManageCollectionPage>
);
const entries = objectEntries(groupedIntegrations);
return filteredIntegrations.length > 0 ? <TourTarget id="manage-integrations-list">{page}</TourTarget> : page;
}
interface IntegrationItemProps {
integration: RouterOutputs["integration"]["all"][number];
canManage: boolean;
}
const IntegrationItem = async ({ integration, canManage }: IntegrationItemProps) => {
const t = await getScopedI18n("integration");
const kindName = getIntegrationName(integration.kind);
const safeUrl = getSafeApplicationUrl(integration.url);
return (
<ActiveTabAccordion defaultValue={activeTab} radius="lg" classNames={classes}>
{entries.map(([kind, kindIntegrations], index) => (
<AccordionItem key={kind} value={kind} data-first={index === 0} data-last={index === entries.length - 1}>
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} radius="sm" />}>
<Group>
<Text>{getIntegrationName(kind)}</Text>
<CountBadge count={kindIntegrations.length} />
</Group>
</AccordionControl>
<AccordionPanel>
<Table visibleFrom="md">
<TableThead>
<TableTr>
<TableTh>{t("field.name.label")}</TableTh>
<TableTh>{t("field.url.label")}</TableTh>
<TableTh />
</TableTr>
</TableThead>
<TableTbody>
{kindIntegrations.map((integration) => (
<TableTr key={integration.id}>
<TableTd>{integration.name}</TableTd>
<TableTd>
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
{integration.url}
</Anchor>
</TableTd>
<TableTd>
<Group justify="end">
{(hasFullAccess || integration.permissions.hasFullAccess) && (
<ActionIconGroup>
<ActionIcon
component={Link}
href={`/manage/integrations/edit/${integration.id}`}
variant="subtle"
color="gray"
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
<DeleteIntegrationActionButton integration={integration} count={kindIntegrations.length} />
</ActionIconGroup>
)}
</Group>
</TableTd>
</TableTr>
))}
</TableTbody>
</Table>
<Stack gap="xs" hiddenFrom="md">
{kindIntegrations.map((integration, integrationIndex) => (
<Fragment key={integration.id}>
{integrationIndex !== 0 && <Divider />}
<Stack gap={0}>
<Group justify="space-between" align="center" wrap="nowrap">
<Text>{integration.name}</Text>
{(hasFullAccess || integration.permissions.hasFullAccess) && (
<ActionIconGroup>
<ActionIcon
component={Link}
href={`/manage/integrations/edit/${integration.id}`}
variant="subtle"
color="gray"
aria-label={t("page.edit.title", { name: getIntegrationName(integration.kind) })}
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
<DeleteIntegrationActionButton integration={integration} count={kindIntegrations.length} />
</ActionIconGroup>
)}
</Group>
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
{integration.url}
</Anchor>
</Stack>
</Fragment>
))}
</Stack>
</AccordionPanel>
</AccordionItem>
))}
</ActiveTabAccordion>
<ManageCollectionItem
leading={<IntegrationAvatar kind={integration.kind} size="md" radius="sm" />}
title={
<Text component="span" fw={600} lineClamp={1}>
{integration.name}
</Text>
}
badges={
<Badge size="sm" variant="light">
{kindName}
</Badge>
}
metadata={
safeUrl ? (
<Anchor
href={safeUrl}
target="_blank"
rel="noreferrer"
lineClamp={1}
size="sm"
style={{ wordBreak: "break-all" }}
>
{integration.url}
</Anchor>
) : undefined
}
actions={
canManage ? (
<ActionIconGroup>
<ActionIcon
component={Link}
href={`/manage/integrations/edit/${integration.id}`}
variant="subtle"
color="gray"
size={44}
aria-label={t("page.list.action.edit", { name: integration.name })}
>
<IconPencil size={18} stroke={1.5} />
</ActionIcon>
<DeleteIntegrationActionButton integration={integration} />
</ActionIconGroup>
) : undefined
}
/>
);
};
@@ -71,7 +71,11 @@ export default async function MediaListPage(props: MediaListPageProps) {
}
toolbar={
<Group>
<SearchInput placeholder={`${t("media.search")}...`} defaultValue={searchParams.search} />
<SearchInput
placeholder={`${t("media.search")}...`}
ariaLabel={t("media.search")}
defaultValue={searchParams.search}
/>
{session.user.permissions.includes("media-view-all") && (
<IncludeFromAllUsersSwitch defaultChecked={searchParams.includeFromAllUsers} />
)}
@@ -17,6 +17,7 @@ interface SearchEngineDeleteButtonProps {
export const SearchEngineDeleteButton = ({ searchEngine }: SearchEngineDeleteButtonProps) => {
const t = useScopedI18n("search.engine.page.delete");
const tList = useScopedI18n("search.engine.page.list.action");
const { openConfirmModal } = useConfirmModal();
const { mutate, isPending } = clientApi.searchEngine.delete.useMutation();
@@ -50,7 +51,14 @@ export const SearchEngineDeleteButton = ({ searchEngine }: SearchEngineDeleteBut
}, [searchEngine, mutate, t, openConfirmModal]);
return (
<ActionIcon loading={isPending} variant="subtle" color="red" onClick={onClick} aria-label={t("title")}>
<ActionIcon
loading={isPending}
variant="subtle"
color="red"
size={44}
onClick={onClick}
aria-label={tList("delete", { name: searchEngine.name })}
>
<IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon>
);
@@ -1,16 +1,17 @@
import { redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Badge, Text } from "@mantine/core";
import { IconPencil, IconSearch } from "@tabler/icons-react";
import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getSafeApplicationUrl } from "@homarr/common";
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { getScopedI18n } from "@homarr/translation/server";
import { Link, SearchInput, TablePagination } from "@homarr/ui";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { ManageCollectionItem, ManageCollectionPage } from "~/components/manage/manage-collection";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { NoResults } from "~/components/no-results";
import { SearchEngineDeleteButton } from "./_search-engine-delete-button";
@@ -27,123 +28,145 @@ interface SearchEnginesPageProps {
export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
const session = await auth();
if (!session) {
redirect("/auth/login");
}
if (!session) redirect("/auth/login");
const searchParams = searchParamsSchema.parse(await props.searchParams);
const { items: searchEngines, totalCount } = await api.searchEngine.getPaginated(searchParams);
const tEngine = await getScopedI18n("search.engine");
const t = await getScopedI18n("search.engine");
const canCreate = session.user.permissions.includes("search-engine-create");
const canModify = session.user.permissions.includes("search-engine-modify-all");
const canDelete = session.user.permissions.includes("search-engine-full-all");
const hasSearch = Boolean(searchParams.search?.trim());
return (
<ManagePageLayout
title={tEngine("page.list.title")}
<ManageCollectionPage
title={t("page.list.title")}
ariaLabel={t("page.list.ariaLabel")}
itemCount={searchEngines.length}
emptyState={
hasSearch ? (
<NoResults
icon={IconSearch}
title={t("page.list.noResults.filteredTitle")}
description={t("page.list.noResults.filteredDescription", { search: searchParams.search ?? "" })}
action={{ label: t("page.list.noResults.clearSearch"), href: "/manage/search-engines" }}
/>
) : (
<NoResults
icon={IconSearch}
title={t("page.list.noResults.title")}
description={t("page.list.noResults.description")}
action={{
label: t("page.list.noResults.action"),
href: "/manage/search-engines/new",
hidden: !canCreate,
}}
/>
)
}
primaryAction={
canCreate ? (
<MobileAffixButton component={Link} href="/manage/search-engines/new">
{tEngine("page.create.title")}
{t("page.create.title")}
</MobileAffixButton>
) : undefined
}
toolbar={<SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} flexExpand />}
footer={<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />}
toolbar={
<SearchInput
placeholder={`${t("search")}...`}
ariaLabel={t("search")}
defaultValue={searchParams.search}
flexExpand
/>
}
footer={
totalCount > searchParams.pageSize ? (
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
) : undefined
}
floatingPrimaryAction={canCreate}
>
{searchEngines.length === 0 && <SearchEngineNoResults />}
{searchEngines.length > 0 && (
<Stack gap="sm">
{searchEngines.map((searchEngine) => (
<SearchEngineCard key={searchEngine.id} searchEngine={searchEngine} />
))}
</Stack>
)}
</ManagePageLayout>
{searchEngines.map((searchEngine) => (
<SearchEngineItem
key={searchEngine.id}
searchEngine={searchEngine}
canModify={canModify}
canDelete={canDelete}
/>
))}
</ManageCollectionPage>
);
}
interface SearchEngineCardProps {
interface SearchEngineItemProps {
searchEngine: RouterOutputs["searchEngine"]["getPaginated"]["items"][number];
canModify: boolean;
canDelete: boolean;
}
const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
const SearchEngineItem = async ({ searchEngine, canModify, canDelete }: SearchEngineItemProps) => {
const t = await getScopedI18n("search.engine");
const session = await auth();
const isIntegration = searchEngine.type === "fromIntegration";
const previewUrl =
searchEngine.type === "generic" && searchEngine.urlTemplate !== null
? getSafeApplicationUrl(searchEngine.urlTemplate.replace("%s", "test"))
: undefined;
return (
<Card>
<Group justify="space-between" wrap="nowrap">
<Group align="top" justify="start" wrap="nowrap" style={{ flex: 1 }}>
<Avatar
<ManageCollectionItem
leading={
<Avatar size={36} src={searchEngine.iconUrl} radius="sm" styles={{ image: { objectFit: "contain" } }} alt="" />
}
title={
<Text component="span" fw={600} lineClamp={1}>
{searchEngine.name}
</Text>
}
badges={
<Badge size="sm" variant="light" color={isIntegration ? "blue" : "gray"}>
{t(`page.list.type.${searchEngine.type}`)}
</Badge>
}
description={
searchEngine.description ? (
<Text size="sm" c="dimmed" lineClamp={2}>
{searchEngine.description}
</Text>
) : undefined
}
metadata={
previewUrl ? (
<Anchor
href={previewUrl}
target="_blank"
rel="noreferrer"
lineClamp={1}
size="sm"
src={searchEngine.iconUrl}
radius={0}
styles={{
image: {
objectFit: "contain",
},
}}
/>
<Stack gap={0}>
<Text fw={500} lineClamp={1}>
{searchEngine.name}
</Text>
{searchEngine.description && (
<Text size="sm" c="gray.6" lineClamp={4}>
{searchEngine.description}
</Text>
)}
{searchEngine.type === "generic" && searchEngine.urlTemplate !== null && (
<Anchor href={searchEngine.urlTemplate.replace("%s", "test")} lineClamp={1} size="sm">
{searchEngine.urlTemplate}
</Anchor>
)}
{searchEngine.type === "fromIntegration" && searchEngine.integrationId !== null && (
<Text c="dimmed" size="sm">
{t("page.list.interactive")}
</Text>
)}
</Stack>
</Group>
<Group>
style={{ wordBreak: "break-all" }}
>
{searchEngine.urlTemplate}
</Anchor>
) : undefined
}
actions={
canModify || canDelete ? (
<ActionIconGroup>
{session?.user.permissions.includes("search-engine-modify-all") && (
{canModify && (
<ActionIcon
component={Link}
href={`/manage/search-engines/edit/${searchEngine.id}`}
variant="subtle"
color="gray"
aria-label={t("page.edit.title")}
size={44}
aria-label={t("page.list.action.edit", { name: searchEngine.name })}
>
<IconPencil size={16} stroke={1.5} />
<IconPencil size={18} stroke={1.5} />
</ActionIcon>
)}
{session?.user.permissions.includes("search-engine-full-all") && (
<SearchEngineDeleteButton searchEngine={searchEngine} />
)}
{canDelete && <SearchEngineDeleteButton searchEngine={searchEngine} />}
</ActionIconGroup>
</Group>
</Group>
</Card>
);
};
const SearchEngineNoResults = async () => {
const t = await getI18n();
const session = await auth();
return (
<NoResults
icon={IconSearch}
title={t("search.engine.page.list.noResults.title")}
action={{
label: t("search.engine.page.list.noResults.action"),
href: "/manage/search-engines/new",
hidden: !session?.user.permissions.includes("search-engine-create"),
}}
) : undefined
}
/>
);
};
@@ -66,7 +66,11 @@ export default async function GroupsDetailPage(props: GroupsDetailPageProps) {
)}
<Group justify="space-between">
<SearchInput placeholder={`${tMembers("search")}...`} defaultValue={searchParams.search} />
<SearchInput
placeholder={`${tMembers("search")}...`}
ariaLabel={tMembers("search")}
defaultValue={searchParams.search}
/>
{canManageMembers && !isReserved && (
<AddGroupMember
groupId={group.id}
+11 -2
View File
@@ -8,6 +8,11 @@ import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import {
getOnboardingClaimTokenFromCookieHeader,
isClaimOnlyOnboardingAccessAllowedAsync,
} from "@homarr/api/onboarding-claim";
import { normalizeOnboardingStep } from "@homarr/api/onboarding-step";
import { auth } from "@homarr/auth/next";
import { env } from "@homarr/common/env";
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
@@ -75,13 +80,17 @@ const reEncryptSecrets = (tempDb: InstanceType<typeof Database>, importedKeyHex:
const isOnboardingActiveAsync = async (): Promise<boolean> => {
const onboardingRow = await db.query.onboarding.findFirst();
if (!onboardingRow) return false;
return onboardingRow.step === "start";
return normalizeOnboardingStep(onboardingRow.step) === "start";
};
export async function POST(req: Request) {
const session = await auth();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const isOnboarding = !isAdmin && (await isOnboardingActiveAsync());
const hasOnboardingClaim = await isClaimOnlyOnboardingAccessAllowedAsync(
db,
getOnboardingClaimTokenFromCookieHeader(req.headers.get("cookie")),
);
const isOnboarding = !isAdmin && hasOnboardingClaim && (await isOnboardingActiveAsync());
if (!isAdmin && !isOnboarding) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
@@ -0,0 +1,56 @@
import { NextResponse } from "next/server";
import {
claimOnboardingAsync,
getOnboardingClaimTokenFromCookieHeader,
onboardingClaimCookieName,
} from "@homarr/api/onboarding-claim";
import { auth } from "@homarr/auth/next";
import { db } from "@homarr/db";
const noStoreHeaders = { "Cache-Control": "no-store" };
export async function POST(request: Request) {
const fetchSite = request.headers.get("sec-fetch-site");
if (fetchSite && fetchSite !== "same-origin" && fetchSite !== "none") {
return NextResponse.json({ error: "Cross-site onboarding claims are not allowed." }, { status: 403 });
}
const session = await auth();
const result = await claimOnboardingAsync(db, {
currentToken: getOnboardingClaimTokenFromCookieHeader(request.headers.get("cookie")),
force: session?.user.permissions.includes("admin") ?? false,
});
if (result.status === "finished") {
const response = NextResponse.json({ error: "Onboarding is already complete." }, { status: 409 });
response.cookies.delete(onboardingClaimCookieName);
return response;
}
if (result.status === "forbidden") {
return NextResponse.json({ error: "Administrator access is required." }, { status: 403 });
}
if (result.status === "locked") {
return NextResponse.json(
{ error: "Another browser has already claimed onboarding.", expiresAt: result.expiresAt },
{ status: 423, headers: noStoreHeaders },
);
}
if (result.status !== "issued" && result.status !== "active") {
return NextResponse.json({ error: "Onboarding could not be claimed." }, { status: 409 });
}
const forwardedProtocol = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
const secure = forwardedProtocol === "https" || new URL(request.url).protocol === "https:";
const response = NextResponse.json(
{ status: result.status, expiresAt: result.expiresAt },
{ headers: noStoreHeaders },
);
response.cookies.set(onboardingClaimCookieName, result.token, {
httpOnly: true,
sameSite: "strict",
secure,
path: "/",
expires: new Date(result.expiresAt),
});
return response;
}
@@ -0,0 +1,68 @@
.canvas {
min-height: 8rem;
aspect-ratio: 16 / 7;
overflow: hidden;
color: var(--mantine-color-dimmed);
background:
radial-gradient(
circle at 15% 10%,
color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent),
transparent 35%
),
var(--mantine-color-body);
}
.lanes {
display: grid;
height: 100%;
gap: rem(8px);
padding: rem(12px);
}
.lane {
display: grid;
min-width: 0;
gap: rem(3px);
padding: rem(4px);
overflow: hidden;
border: rem(1px) solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-sm);
background-image:
linear-gradient(
to right,
color-mix(in srgb, var(--mantine-color-dimmed) 8%, transparent) rem(1px),
transparent rem(1px)
),
linear-gradient(
to bottom,
color-mix(in srgb, var(--mantine-color-dimmed) 8%, transparent) rem(1px),
transparent rem(1px)
);
background-size: rem(12px) rem(12px);
}
.lane[data-lane="left"],
.lane[data-lane="right"] {
background-color: color-mix(in srgb, var(--mantine-primary-color-light) 40%, transparent);
}
.tile {
min-width: 0;
min-height: 0;
border: rem(1px) solid color-mix(in srgb, var(--mantine-primary-color-filled) 32%, transparent);
border-radius: max(rem(2px), calc(var(--mantine-radius-default) / 2));
background: color-mix(in srgb, var(--mantine-primary-color-filled) 18%, var(--mantine-color-body));
box-shadow: 0 rem(1px) rem(3px) rgb(0 0 0 / 8%);
}
.container {
border-style: dashed;
border-color: color-mix(in srgb, var(--mantine-color-teal-filled) 45%, transparent);
background: color-mix(in srgb, var(--mantine-color-teal-light) 55%, var(--mantine-color-body));
}
@media (max-width: $mantine-breakpoint-xs) {
.canvas {
min-height: 7rem;
}
}
@@ -0,0 +1,91 @@
import { Box, Center } from "@mantine/core";
import { IconLayoutGrid } from "@tabler/icons-react";
import type { BoardPreviewData } from "@homarr/boards/layout-preview";
import { projectBoardLayout } from "@homarr/boards/layout-preview";
import { boardLanes, getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
import classes from "./board-layout-thumbnail.module.css";
interface BoardLayoutThumbnailProps {
preview: BoardPreviewData | null;
label: string;
}
const maxThumbnailRows = 12;
export const BoardLayoutThumbnail = ({ preview, label }: BoardLayoutThumbnailProps) => {
const layout = preview?.layouts.at(0);
if (!preview || !layout) {
return (
<Center className={classes.canvas} role="img" aria-label={label}>
<IconLayoutGrid size={28} stroke={1.4} aria-hidden />
</Center>
);
}
const elements = projectBoardLayout(preview, layout, layout);
const roots = preview.sections.filter((section) => section.kind === "empty");
const lanes = boardLanes.flatMap((lane) => {
const root = roots.find((section) => getRootSectionLane(section.xOffset) === lane);
const columnCount = getBoardLaneColumnCount(layout, lane);
return root && columnCount > 0 ? [{ lane, root, columnCount }] : [];
});
if (lanes.length === 0) {
return (
<Center className={classes.canvas} role="img" aria-label={label}>
<IconLayoutGrid size={28} stroke={1.4} aria-hidden />
</Center>
);
}
return (
<Box className={classes.canvas} role="img" aria-label={label}>
<div
className={classes.lanes}
style={{ gridTemplateColumns: lanes.map(({ columnCount }) => `${columnCount}fr`).join(" ") }}
>
{lanes.map(({ lane, root, columnCount }) => {
const laneElements = elements.filter(
(element) =>
element.sectionId === root.id &&
element.xOffset >= 0 &&
element.xOffset < columnCount &&
element.yOffset >= 0 &&
element.yOffset < maxThumbnailRows &&
element.width > 0 &&
element.height > 0,
);
const rowCount = Math.min(
maxThumbnailRows,
Math.max(4, ...laneElements.map((element) => element.yOffset + element.height)),
);
return (
<div
key={lane}
className={classes.lane}
data-lane={lane}
style={{
gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${rowCount}, minmax(0, 1fr))`,
}}
>
{laneElements.map((element) => (
<span
key={`${element.type}-${element.id}`}
aria-hidden
className={`${classes.tile} ${element.type === "section" ? classes.container : ""}`}
style={{
gridColumn: `${element.xOffset + 1} / span ${Math.min(element.width, columnCount - element.xOffset)}`,
gridRow: `${element.yOffset + 1} / span ${Math.min(element.height, rowCount - element.yOffset)}`,
}}
/>
))}
</div>
);
})}
</div>
</Box>
);
};
@@ -196,6 +196,7 @@ const ItemSelectModalContent = ({
search={search}
onSearchChange={setSearch}
placeholder={`${t("item.create.search")}...`}
ariaLabel={t("item.create.search")}
onSearchKeyDown={(event) => {
if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) {
const [item] = filteredItems;
@@ -1,76 +1,6 @@
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import {
featuredIntegrations,
getIntegrationName,
hiddenFromOnboarding,
integrationDefs,
integrationKinds,
getWidgetKindsForIntegration,
} from "@homarr/definitions";
export const categoryTranslationKeys: Record<string, string> = {
dnsHole: "integration.category.dnsHole",
mediaService: "integration.category.mediaService",
calendar: "integration.category.calendar",
mediaSearch: "integration.category.mediaSearch",
mediaRelease: "integration.category.mediaRelease",
mediaRequest: "integration.category.mediaRequest",
downloadClient: "integration.category.downloadClient",
usenet: "integration.category.usenet",
torrent: "integration.category.torrent",
miscellaneous: "integration.category.miscellaneous",
smartHomeServer: "integration.category.smartHomeServer",
indexerManager: "integration.category.indexerManager",
healthMonitoring: "integration.category.healthMonitoring",
beszel: "integration.category.beszel",
search: "integration.category.search",
mediaTranscoding: "integration.category.mediaTranscoding",
networkController: "integration.category.networkController",
notifications: "integration.category.notifications",
firewall: "integration.category.firewall",
photoService: "integration.category.photoService",
notes: "integration.category.notes",
mediaMonitoring: "integration.category.mediaMonitoring",
speedtest: "integration.category.speedtest",
analytics: "integration.category.analytics",
vpn: "integration.category.vpn",
documents: "integration.category.documents",
mediaLibrary: "integration.category.mediaLibrary",
uptimeMonitoring: "integration.category.uptimeMonitoring",
};
export interface IntegrationGridItem {
kind: IntegrationKind;
name: string;
categories: string[];
widgets: WidgetKind[];
}
export const buildSortedIntegrations = (
options: { enableMockIntegration?: boolean; onboarding?: boolean } = {},
): IntegrationGridItem[] => {
return integrationKinds
.filter((kind) => {
if (options.onboarding && hiddenFromOnboarding.has(kind)) return false;
if (options.onboarding && getWidgetKindsForIntegration(kind).length === 0) return false;
if (!options.enableMockIntegration && kind === "mock") return false;
return true;
})
.map((kind) => ({
kind,
name: getIntegrationName(kind),
categories: [...new Set(integrationDefs[kind].category.flat())] as string[],
widgets: getWidgetKindsForIntegration(kind),
}))
.toSorted((left, right) => {
const leftIdx = featuredIntegrations.indexOf(left.kind);
const rightIdx = featuredIntegrations.indexOf(right.kind);
if (leftIdx !== -1 && rightIdx !== -1) return leftIdx - rightIdx;
if (leftIdx !== -1) return -1;
if (rightIdx !== -1) return 1;
return right.widgets.length - left.widgets.length || left.name.localeCompare(right.name);
});
};
export const filterIntegrations = (items: IntegrationGridItem[], search: string) =>
items.filter((i) => i.name.toLowerCase().includes(search.toLowerCase().trim()));
export {
buildSortedIntegrations,
categoryTranslationKeys,
filterIntegrations,
} from "@homarr/ui/integration-select-grid";
export type { IntegrationGridItem } from "@homarr/ui/integration-select-grid";
@@ -1,140 +1,2 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { Badge, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
import { IconBrandDocker, IconCheck, IconPuzzle } from "@tabler/icons-react";
import type { IntegrationKind } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar, SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
import { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared";
interface IntegrationMultiSelectGridProps {
selectedKinds: IntegrationKind[];
onSelectionChange: (kinds: IntegrationKind[]) => void;
enableMockIntegration?: boolean;
onboarding?: boolean;
detectedKinds?: Set<IntegrationKind>;
}
export const IntegrationMultiSelectGrid = ({
selectedKinds: selectedKindsArray,
onSelectionChange,
enableMockIntegration = false,
onboarding = false,
detectedKinds,
}: IntegrationMultiSelectGridProps) => {
const [search, setSearch] = useState("");
const selectedKinds = useMemo(() => new Set(selectedKindsArray), [selectedKindsArray]);
const t = useI18n();
const toggleKind = useCallback(
(kind: IntegrationKind) => {
const current = new Set(selectedKindsArray);
if (current.has(kind)) {
current.delete(kind);
} else {
current.add(kind);
}
onSelectionChange(Array.from(current));
},
[selectedKindsArray, onSelectionChange],
);
const integrations = useMemo(
() => buildSortedIntegrations({ enableMockIntegration, onboarding }),
[enableMockIntegration, onboarding],
);
const sorted = useMemo(() => {
if (!detectedKinds || detectedKinds.size === 0) return integrations;
const detected = integrations.filter((i) => detectedKinds.has(i.kind));
const rest = integrations.filter((i) => !detectedKinds.has(i.kind));
return [...detected, ...rest];
}, [integrations, detectedKinds]);
const filtered = useMemo(() => filterIntegrations(sorted, search), [sorted, search]);
return (
<SelectGridLayout
search={search}
onSearchChange={setSearch}
placeholder={`${t("integration.page.list.search")}...`}
disableScroll={onboarding}
disableAutoFocus={onboarding}
>
{filtered.map((integration) => {
const isSelected = selectedKinds.has(integration.kind);
const isDetected = detectedKinds?.has(integration.kind) ?? false;
return (
<Card
key={integration.kind}
h={selectGridCardHeight}
style={{
cursor: "pointer",
borderColor: isSelected ? "var(--mantine-color-blue-6)" : undefined,
borderWidth: isSelected ? 2 : undefined,
}}
onClick={() => toggleKind(integration.kind)}
withBorder
>
<Stack justify="space-between" h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
<IntegrationAvatar kind={integration.kind} size="sm" />
<Text fw={500} lh={1.2} size="sm" lineClamp={2} style={{ flex: 1 }}>
{integration.name}
</Text>
{isSelected && <IconCheck size={18} color="var(--mantine-color-blue-6)" style={{ flexShrink: 0 }} />}
</Group>
<Stack gap={4} mt="auto">
{isDetected && (
<Badge variant="light" color="teal" size="xs" leftSection={<IconBrandDocker size={12} />}>
{t("integration.grid.detected")}
</Badge>
)}
<Group gap={4} wrap="wrap">
{integration.categories.slice(0, 2).map((cat) => (
<Badge key={cat} variant="light" size="xs">
{categoryTranslationKeys[cat] ? t(categoryTranslationKeys[cat] as never) : cat}
</Badge>
))}
</Group>
{integration.widgets.length > 0 ? (
<Tooltip
multiline
w={200}
label={
<Stack gap={4}>
{integration.widgets.map((widgetKind) => (
<Text key={widgetKind} size="xs">
{t(`widget.${widgetKind}.name`)}
</Text>
))}
</Stack>
}
>
<Badge variant="light" color="blue" size="sm" leftSection={<IconPuzzle size={12} />}>
{t("integration.grid.widgetCount", { count: String(integration.widgets.length) })}
</Badge>
</Tooltip>
) : (
<Badge variant="light" color="gray" size="sm">
{t("integration.grid.noWidgets")}
</Badge>
)}
</Stack>
</Stack>
</Card>
);
})}
{filtered.length === 0 && (
<Center p="xl">
<Text c="dimmed">{t("common.noResults")}</Text>
</Center>
)}
</SelectGridLayout>
);
};
export { IntegrationMultiSelectGrid } from "@homarr/ui/integration-select-grid";
export type { IntegrationMultiSelectGridProps } from "@homarr/ui/integration-select-grid";
@@ -1,90 +1,2 @@
"use client";
import { useMemo, useState } from "react";
import { Badge, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
import { IconPuzzle } from "@tabler/icons-react";
import type { IntegrationKind } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar, SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
import { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared";
interface IntegrationSelectGridProps {
onSelect: (kind: IntegrationKind) => void;
enableMockIntegration?: boolean;
}
export const IntegrationSelectGrid = ({ onSelect, enableMockIntegration = false }: IntegrationSelectGridProps) => {
const [search, setSearch] = useState("");
const t = useI18n();
const integrations = useMemo(() => buildSortedIntegrations({ enableMockIntegration }), [enableMockIntegration]);
const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]);
return (
<SelectGridLayout
search={search}
onSearchChange={setSearch}
placeholder={`${t("integration.page.list.search")}...`}
>
{filtered.map((integration) => (
<Card
key={integration.kind}
h={selectGridCardHeight}
style={{ cursor: "pointer" }}
onClick={() => onSelect(integration.kind)}
withBorder
>
<Stack justify="space-between" h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
<IntegrationAvatar kind={integration.kind} size="sm" />
<Text fw={500} lh={1.2} size="sm" lineClamp={2}>
{integration.name}
</Text>
</Group>
<Stack gap={4} mt="auto">
<Group gap={4} wrap="wrap">
{integration.categories.slice(0, 2).map((cat) => (
<Badge key={cat} variant="light" size="xs">
{categoryTranslationKeys[cat] ? t(categoryTranslationKeys[cat] as never) : cat}
</Badge>
))}
</Group>
{integration.widgets.length > 0 ? (
<Tooltip
multiline
w={200}
label={
<Stack gap={4}>
{integration.widgets.map((widgetKind) => (
<Text key={widgetKind} size="xs">
{t(`widget.${widgetKind}.name`)}
</Text>
))}
</Stack>
}
>
<Badge variant="light" color="blue" size="sm" leftSection={<IconPuzzle size={12} />}>
{t("integration.grid.widgetCount", { count: String(integration.widgets.length) })}
</Badge>
</Tooltip>
) : (
<Badge variant="light" color="gray" size="sm">
{t("integration.grid.noWidgets")}
</Badge>
)}
</Stack>
</Stack>
</Card>
))}
{filtered.length === 0 && (
<Center p="xl">
<Text c="dimmed">{t("common.noResults")}</Text>
</Center>
)}
</SelectGridLayout>
);
};
export { IntegrationSelectGrid } from "@homarr/ui/integration-select-grid";
export type { IntegrationSelectGridProps } from "@homarr/ui/integration-select-grid";
@@ -0,0 +1,67 @@
import type { ReactNode } from "react";
import { Box, Group, Paper, Stack } from "@mantine/core";
import type { ManagePageLayoutProps } from "./manage-page-layout";
import { ManagePageLayout } from "./manage-page-layout";
export interface ManageCollectionPageProps extends Omit<ManagePageLayoutProps, "children"> {
ariaLabel: string;
itemCount: number;
emptyState: ReactNode;
children: ReactNode;
}
export const ManageCollectionPage = ({
ariaLabel,
itemCount,
emptyState,
children,
...layoutProps
}: ManageCollectionPageProps) => (
<ManagePageLayout {...layoutProps}>
{itemCount === 0 ? (
emptyState
) : (
<Stack component="ul" aria-label={ariaLabel} gap="sm" m={0} p={0} style={{ listStyle: "none" }}>
{children}
</Stack>
)}
</ManagePageLayout>
);
export interface ManageCollectionItemProps {
leading?: ReactNode;
title: ReactNode;
description?: ReactNode;
metadata?: ReactNode;
badges?: ReactNode;
actions?: ReactNode;
}
export const ManageCollectionItem = ({
leading,
title,
description,
metadata,
badges,
actions,
}: ManageCollectionItemProps) => (
<Paper component="li" p="sm" withBorder radius="md">
<Group justify="space-between" align="flex-start" wrap="nowrap" gap="sm">
{leading && <Box style={{ flexShrink: 0 }}>{leading}</Box>}
<Stack gap={4} flex={1} miw={0}>
<Group gap="xs" wrap="wrap">
{title}
{badges}
</Group>
{description}
{metadata}
</Stack>
{actions && (
<Group gap={4} wrap="nowrap" style={{ flexShrink: 0 }}>
{actions}
</Group>
)}
</Group>
</Paper>
);
@@ -6,7 +6,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { ManageContainer } from "./manage-container";
import { MANAGE_FLOATING_ACTION_BOTTOM_OFFSET } from "./manage-page.constants";
interface ManagePageLayoutProps {
export interface ManagePageLayoutProps {
title: ReactNode;
primaryAction?: ReactNode;
toolbar?: ReactNode;
+8 -1
View File
@@ -1,3 +1,4 @@
import type { ReactNode } from "react";
import { Anchor, Card, Stack, Text } from "@mantine/core";
import type { TablerIcon } from "@homarr/ui";
@@ -5,6 +6,7 @@ import type { TablerIcon } from "@homarr/ui";
interface NoResultsProps {
icon: TablerIcon;
title: string;
description?: ReactNode;
action?: {
label: string;
href: string;
@@ -12,7 +14,7 @@ interface NoResultsProps {
};
}
export const NoResults = ({ icon: Icon, title, action }: NoResultsProps) => {
export const NoResults = ({ icon: Icon, title, description, action }: NoResultsProps) => {
return (
<Card bg="transparent" withBorder={false}>
<Stack align="center" gap="sm">
@@ -20,6 +22,11 @@ export const NoResults = ({ icon: Icon, title, action }: NoResultsProps) => {
<Text fw={500} size="lg">
{title}
</Text>
{description && (
<Text c="dimmed" ta="center" maw={560}>
{description}
</Text>
)}
{action && !action.hidden && <Anchor href={action.href}>{action.label}</Anchor>}
</Stack>
</Card>
+10
View File
@@ -62,6 +62,16 @@ describe("locale and onboarding proxy", () => {
expect(mocks.getDefaultLocale).toHaveBeenCalledOnce();
});
it("allows administrator sign-in while onboarding is unfinished", async () => {
mocks.getOnboardingStep.mockResolvedValue("setup");
const { proxy } = await loadProxy();
const response = await proxy(createRequest("/en/auth/login"));
expect(response.status).toBe(200);
expect(mocks.getOnboardingStep).not.toHaveBeenCalled();
});
it("uses the configured default locale for a missing or invalid locale cookie", async () => {
mocks.getDefaultLocale.mockResolvedValue("de");
const { proxy } = await loadProxy();
+2 -1
View File
@@ -42,8 +42,9 @@ const getDefaultLocaleDedupedAsync = () => {
export async function proxy(request: NextRequest) {
// Redirect to onboarding if it's not finished yet
const pathname = request.nextUrl.pathname;
const isOnboardingAccessRoute = /\/(?:init|auth\/login)\/?$/.test(pathname);
if (!isOnboardingFinished && !pathname.endsWith("/init")) {
if (!isOnboardingFinished && !isOnboardingAccessRoute) {
const currentOnboardingStep = await getOnboardingStepDedupedAsync();
if (currentOnboardingStep !== "finish") {
return NextResponse.redirect(new URL("/init", request.url));