Compare commits

..
Author SHA1 Message Date
Thomas 9239e6ecc5 Merge branch 'dev' into perf/parallelize-board-prefetch 2026-05-27 09:38:16 +02:00
Thomas 8bb284e1d3 Delete apps/nextjs/src/app/[locale]/boards/(content)/_creator.spec.ts 2026-05-25 09:42:38 +02:00
Thomas Camlong 52fc678e44 test(boards): add tests for parallelized board page data fetching 2026-05-22 09:00:02 +02:00
Thomas Camlong 136c269935 perf(boards): parallelize board page data fetching
Board fetch + auth() now run concurrently via Promise.all instead of
sequentially. Widget prefetches for all kinds also run in parallel
alongside integration permission loading.
2026-05-21 14:01:01 +02:00
389 changed files with 3220 additions and 16861 deletions

No files matched your search

+5 -5
View File
@@ -42,14 +42,14 @@ jobs:
- name: Get changed files - name: Get changed files
id: changed id: changed
run: | run: |
git diff --name-only --diff-filter=ACMR origin/${{ github.base_ref }}...HEAD -- '*.ts' '*.tsx' '*.js' '*.jsx' > /tmp/changed-files FILES=$(git diff --name-only --diff-filter=ACMR origin/${{ github.base_ref }}...HEAD -- '*.ts' '*.tsx' '*.js' '*.jsx' | tr '\n' ' ')
echo "count=$(wc -l < /tmp/changed-files | tr -d '[:space:]')" >> $GITHUB_OUTPUT echo "files=$FILES" >> $GITHUB_OUTPUT
- name: Auto-fix lint + format - name: Auto-fix lint + format
if: steps.changed.outputs.count != '0' if: steps.changed.outputs.files != ''
run: | run: |
xargs -r -a /tmp/changed-files pnpm exec oxlint --fix || true pnpm exec oxlint --fix ${{ steps.changed.outputs.files }} || true
xargs -r -a /tmp/changed-files pnpm exec oxfmt --write pnpm exec oxfmt --write ${{ steps.changed.outputs.files }}
- name: Commit fixes - name: Commit fixes
uses: stefanzweifel/git-auto-commit-action@v5 uses: stefanzweifel/git-auto-commit-action@v5
@@ -66,7 +66,7 @@ jobs:
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
if: env.SKIP_RELEASE == 'false' if: env.SKIP_RELEASE == 'false'
with: with:
node-version: 24.16.0 node-version: 24.15.0
package-manager-cache: false package-manager-cache: false
- run: npm i -g pnpm - run: npm i -g pnpm
if: env.SKIP_RELEASE == 'false' if: env.SKIP_RELEASE == 'false'
-3
View File
@@ -1,3 +0,0 @@
[tools]
node = "24.16.0"
pnpm = "10.33.4"
+1 -1
View File
@@ -1 +1 @@
24.16.0 24.15.0
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24.16.0-alpine AS base FROM node:24.15.0-alpine AS base
FROM base AS builder FROM base AS builder
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
+1 -3
View File
@@ -7,7 +7,7 @@
"build": "pnpm with-env next build", "build": "pnpm with-env next build",
"clean": "git clean -xdf .next .turbo node_modules", "clean": "git clean -xdf .next .turbo node_modules",
"dev": "pnpm with-env next dev", "dev": "pnpm with-env next dev",
"format": "oxfmt .", "format": "oxfmt --check .",
"lint": "oxlint", "lint": "oxlint",
"start": "pnpm with-env next start", "start": "pnpm with-env next start",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
@@ -18,7 +18,6 @@
"@dnd-kit/modifiers": "catalog:", "@dnd-kit/modifiers": "catalog:",
"@dnd-kit/sortable": "catalog:", "@dnd-kit/sortable": "catalog:",
"@dnd-kit/utilities": "catalog:", "@dnd-kit/utilities": "catalog:",
"@gfazioli/mantine-onboarding-tour": "catalog:",
"@homarr/analytics": "workspace:^0.1.0", "@homarr/analytics": "workspace:^0.1.0",
"@homarr/api": "workspace:^0.1.0", "@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0", "@homarr/auth": "workspace:^0.1.0",
@@ -57,7 +56,6 @@
"@mantine/modals": "catalog:", "@mantine/modals": "catalog:",
"@mantine/tiptap": "catalog:", "@mantine/tiptap": "catalog:",
"@tabler/icons-react": "catalog:", "@tabler/icons-react": "catalog:",
"@tanstack/query-persist-client-core": "catalog:",
"@tanstack/react-query": "catalog:", "@tanstack/react-query": "catalog:",
"@tanstack/react-query-devtools": "catalog:", "@tanstack/react-query-devtools": "catalog:",
"@tanstack/react-query-next-experimental": "catalog:", "@tanstack/react-query-next-experimental": "catalog:",
@@ -2,14 +2,13 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core"; import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
import { DirectionProvider, MantineProvider, v8CssVariablesResolver } from "@mantine/core"; import { createTheme, DirectionProvider, MantineProvider } from "@mantine/core";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import { parseCookies, setClientCookie } from "@homarr/common"; import { parseCookies, setClientCookie } from "@homarr/common";
import { colorSchemeCookieKey } from "@homarr/definitions"; import { colorSchemeCookieKey } from "@homarr/definitions";
import { theme } from "@homarr/ui";
export const CustomMantineProvider = ({ export const CustomMantineProvider = ({
children, children,
@@ -21,8 +20,10 @@ export const CustomMantineProvider = ({
<MantineProvider <MantineProvider
defaultColorScheme={defaultColorScheme} defaultColorScheme={defaultColorScheme}
colorSchemeManager={manager} colorSchemeManager={manager}
theme={theme} theme={createTheme({
cssVariablesResolver={v8CssVariablesResolver} primaryColor: "red",
autoContrast: true,
})}
> >
{children} {children}
</MantineProvider> </MantineProvider>
@@ -1,49 +0,0 @@
import type { AsyncStorage, PersistedQuery } from "@tanstack/query-persist-client-core";
import { experimental_createQueryPersister } from "@tanstack/query-persist-client-core";
import { parse, stringify } from "superjson";
import { fetchApi } from "@homarr/api/client";
import {
getActiveQueryCacheBoardId,
isPersistableWidgetQueryKey,
queryCacheStoragePrefix,
} from "@homarr/api/query-cache";
const queryCacheStorage = {
async getItem(_key) {
return null;
},
async setItem(key, value) {
const boardId = getActiveQueryCacheBoardId();
if (!boardId) return;
try {
await fetchApi.queryCache.setItem.mutate({ boardId, key, value });
} catch {
// Query persistence is best-effort; failed writes should not affect widget rendering.
}
},
async removeItem(key) {
const boardId = getActiveQueryCacheBoardId();
if (!boardId) return;
try {
await fetchApi.queryCache.removeItem.mutate({ boardId, key });
} catch {
// Query persistence is best-effort; failed removals should not affect widget rendering.
}
},
} satisfies AsyncStorage<string>;
export const createWidgetQueryPersister = () =>
experimental_createQueryPersister<string>({
storage: typeof window === "undefined" ? undefined : queryCacheStorage,
maxAge: Number.POSITIVE_INFINITY,
prefix: queryCacheStoragePrefix,
refetchOnRestore: true,
serialize: (query) => stringify(query),
deserialize: (value) => parse<PersistedQuery>(value),
filters: {
predicate: (query) => isPersistableWidgetQueryKey(query.queryKey),
},
});
@@ -19,12 +19,9 @@ import type { SuperJSONResult } from "superjson";
import type { AppRouter } from "@homarr/api"; import type { AppRouter } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { queryCacheDefaultStaleTimeMs } from "@homarr/api/query-cache";
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared"; import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
import { env } from "@homarr/common/env"; import { env } from "@homarr/common/env";
import { createWidgetQueryPersister } from "./query-cache-persister";
const getWebSocketProtocol = () => { const getWebSocketProtocol = () => {
// window is not defined on server side // window is not defined on server side
if (typeof window === "undefined") { if (typeof window === "undefined") {
@@ -52,14 +49,12 @@ const wsClient = createWSClient({
}); });
export function TRPCReactProvider(props: PropsWithChildren) { export function TRPCReactProvider(props: PropsWithChildren) {
const [queryPersister] = useState(() => createWidgetQueryPersister());
const [queryClient] = useState( const [queryClient] = useState(
() => () =>
new QueryClient({ new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
persister: queryPersister.persisterFn, staleTime: 5 * 1000,
staleTime: queryCacheDefaultStaleTimeMs,
}, },
}, },
}), }),
@@ -59,7 +59,7 @@ export default async function InviteUsagePage(props: InviteUsagePageProps) {
{t("subtitle")} {t("subtitle")}
</Text> </Text>
</Stack> </Stack>
<Card w={64 * 6} maw="90vw"> <Card withBorder w={64 * 6} maw="90vw">
<RegistrationForm invite={invite} /> <RegistrationForm invite={invite} />
</Card> </Card>
<Text size="xs" c="gray.5" ta="center"> <Text size="xs" c="gray.5" ta="center">
@@ -9,7 +9,7 @@ import { z } from "zod/v4";
import { signIn } from "@homarr/auth/client"; import { signIn } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import type { UseFormReturnType } from "@homarr/form"; import type { useForm } from "@homarr/form";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
@@ -162,7 +162,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c
interface SubmitButtonProps { interface SubmitButtonProps {
isPending: boolean; isPending: boolean;
form: UseFormReturnType<FormType>; form: ReturnType<typeof useForm<FormType, (values: FormType) => FormType>>;
provider: "credentials" | "ldap"; provider: "credentials" | "ldap";
} }
@@ -198,7 +198,7 @@ const PasswordForgottenCollapse = ({ username }: PasswordForgottenCollapseProps)
{tForgotPassword("label")} {tForgotPassword("label")}
</Anchor> </Anchor>
<Collapse expanded={visible}> <Collapse in={visible}>
<Card> <Card>
<Stack gap="xs"> <Stack gap="xs">
<Text size="sm">{tForgotPassword("description")}</Text> <Text size="sm">{tForgotPassword("description")}</Text>
@@ -37,7 +37,7 @@ export default async function Login(props: LoginProps) {
{t("subtitle")} {t("subtitle")}
</Text> </Text>
</Stack> </Stack>
<Card w={64 * 6} maw="90vw"> <Card withBorder w={64 * 6} maw="90vw">
<LoginForm <LoginForm
providers={env.AUTH_PROVIDERS} providers={env.AUTH_PROVIDERS}
oidcClientName={env.AUTH_OIDC_CLIENT_NAME} oidcClientName={env.AUTH_OIDC_CLIENT_NAME}
@@ -1,14 +1,10 @@
import { cache } from "react";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Suspense } from "react";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
// Placed here because gridstack styles are used for board content // Placed here because gridstack styles are used for board content
import "~/styles/gridstack.scss"; import "~/styles/gridstack.scss";
import type { DehydratedState } from "@tanstack/react-query";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { parse } from "superjson";
import { getQueryClient } from "@homarr/api/server"; import { getQueryClient } from "@homarr/api/server";
import { IntegrationProvider } from "@homarr/auth/client"; import { IntegrationProvider } from "@homarr/auth/client";
@@ -18,7 +14,6 @@ import { isNullOrWhitespace } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs"; import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { WidgetKind } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions";
import { getAllQueryCacheAsync } from "@homarr/redis";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { prefetchForKindAsync } from "@homarr/widgets/prefetch"; import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
@@ -39,21 +34,17 @@ interface Props<TParams extends Params> {
export const createBoardContentPage = <TParams extends Record<string, unknown>>({ export const createBoardContentPage = <TParams extends Record<string, unknown>>({
getInitialBoardAsync: getInitialBoard, getInitialBoardAsync: getInitialBoard,
}: Props<TParams>) => { }: Props<TParams>) => {
const getCachedBoard = cache((_paramsKey: string, params: TParams) => getInitialBoard(params));
const getBoardForParams = (params: TParams) => getCachedBoard(JSON.stringify(params), params);
return { return {
layout: createBoardLayout({ layout: createBoardLayout({
headerActions: <BoardContentHeaderActions />, headerActions: <BoardContentHeaderActions />,
getInitialBoardAsync: getBoardForParams, getInitialBoardAsync: getInitialBoard,
withTour: true,
}), }),
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
page: async ({ params }: { params: Promise<TParams> }) => { page: async ({ params }: { params: Promise<TParams> }) => {
const resolvedParams = await params; const resolvedParams = await params;
const queryClient = getQueryClient(); const queryClient = getQueryClient();
const [board, session] = await Promise.all([getBoardForParams(resolvedParams), auth()]); const [board, session] = await Promise.all([getInitialBoard(resolvedParams), auth()]);
const itemsMap = board.items.reduce((acc, item) => { const itemsMap = board.items.reduce((acc, item) => {
const existing = acc.get(item.kind); const existing = acc.get(item.kind);
@@ -80,14 +71,9 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
), ),
]); ]);
const userId = session?.user.id ?? "anonymous";
return ( return (
<HydrationBoundary state={dehydrate(queryClient)}> <HydrationBoundary state={dehydrate(queryClient)}>
<IntegrationProvider integrations={integrations}> <IntegrationProvider integrations={integrations}>
<Suspense>
<QueryCacheHydration userId={userId} boardId={board.id} />
</Suspense>
<DynamicClientBoard /> <DynamicClientBoard />
</IntegrationProvider> </IntegrationProvider>
</HydrationBoundary> </HydrationBoundary>
@@ -95,7 +81,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
}, },
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => { generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
try { try {
const board = await getBoardForParams(await params); const board = await getInitialBoard(await params);
const t = await getI18n(); const t = await getI18n();
return { return {
@@ -111,6 +97,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
}, },
}; };
} catch (error) { } catch (error) {
// Ignore not found errors and return empty metadata
if (error instanceof TRPCError && error.code === "NOT_FOUND") { if (error instanceof TRPCError && error.code === "NOT_FOUND") {
return {}; return {};
} }
@@ -120,37 +107,3 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
}, },
}; };
}; };
interface PersistedEntry {
state: { data: unknown; dataUpdatedAt: number; status: string };
queryKey: unknown[];
queryHash: string;
}
async function QueryCacheHydration({ userId, boardId }: { userId: string; boardId: string }) {
try {
const entries = await getAllQueryCacheAsync(userId, boardId);
const queries: DehydratedState["queries"] = [];
for (const serialized of Object.values(entries)) {
try {
const persisted = parse<PersistedEntry>(serialized);
if (persisted.state.status !== "success") continue;
queries.push({
state: persisted.state as DehydratedState["queries"][number]["state"],
queryKey: persisted.queryKey,
queryHash: persisted.queryHash,
});
} catch {
// skip malformed entries
}
}
if (queries.length === 0) return null;
return <HydrationBoundary state={{ mutations: [], queries }} />;
} catch {
return null;
}
}
@@ -2,8 +2,7 @@
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour"; import { Group, Menu, ScrollArea } from "@mantine/core";
import { Box, Group, Menu, ScrollArea } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks"; import { useHotkeys } from "@mantine/hooks";
import { import {
IconBox, IconBox,
@@ -12,7 +11,6 @@ import {
IconLayoutBoard, IconLayoutBoard,
IconPencil, IconPencil,
IconPencilOff, IconPencilOff,
IconPlug,
IconPlus, IconPlus,
IconReplace, IconReplace,
IconResize, IconResize,
@@ -38,7 +36,6 @@ import { useBoardPermissions } from "~/components/board/permissions/client";
import { useCategoryActions } from "~/components/board/sections/category/category-actions"; import { useCategoryActions } from "~/components/board/sections/category/category-actions";
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal"; import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions"; import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { HeaderButton } from "~/components/layout/header/button"; import { HeaderButton } from "~/components/layout/header/button";
export const BoardContentHeaderActions = () => { export const BoardContentHeaderActions = () => {
@@ -56,11 +53,9 @@ export const BoardContentHeaderActions = () => {
<EditModeMenu /> <EditModeMenu />
<OnboardingTour.Target id="board-settings"> <HeaderButton href={`/boards/${board.name}/settings`}>
<HeaderButton href={`/boards/${board.name}/settings`}> <IconSettings stroke={1.5} />
<IconSettings stroke={1.5} /> </HeaderButton>
</HeaderButton>
</OnboardingTour.Target>
<SelectBoardsMenu /> <SelectBoardsMenu />
</> </>
@@ -72,7 +67,6 @@ const AddMenu = () => {
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal); const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal); const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal); const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
const { addCategoryToEnd } = useCategoryActions(); const { addCategoryToEnd } = useCategoryActions();
const { addDynamicSection } = useDynamicSectionActions(); const { addDynamicSection } = useDynamicSectionActions();
const { createItem } = useItemActions(); const { createItem } = useItemActions();
@@ -114,12 +108,8 @@ const AddMenu = () => {
}); });
}, [openAppSelectModal, createItem]); }, [openAppSelectModal, createItem]);
const handleAddIntegration = useCallback(() => {
openIntegrationSelectModal({});
}, [openIntegrationSelectModal]);
return ( return (
<Menu position="bottom-end"> <Menu position="bottom-end" withArrow>
<Menu.Target> <Menu.Target>
<HeaderButton w="auto" px={4}> <HeaderButton w="auto" px={4}>
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">
@@ -137,10 +127,6 @@ const AddMenu = () => {
{t("app.action.add")} {t("app.action.add")}
</Menu.Item> </Menu.Item>
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
{t("integration.action.create")}
</Menu.Item>
<Menu.Divider /> <Menu.Divider />
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}> <Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
@@ -187,11 +173,9 @@ const EditModeMenu = () => {
usePreventLeaveWithDirty(isEditMode); usePreventLeaveWithDirty(isEditMode);
return ( return (
<OnboardingTour.Target id="board-edit-mode"> <HeaderButton onClick={toggle} loading={isPending}>
<HeaderButton onClick={toggle} loading={isPending}> {isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />} </HeaderButton>
</HeaderButton>
</OnboardingTour.Target>
); );
}; };
@@ -199,31 +183,27 @@ const SelectBoardsMenu = () => {
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery(); const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
return ( return (
<OnboardingTour.Target id="board-switcher"> <Menu position="bottom-end" withArrow>
<Box> <Menu.Target>
<Menu position="bottom-end"> <HeaderButton w="auto" px={4}>
<Menu.Target> <IconReplace stroke={1.5} />
<HeaderButton w="auto" px={4}> </HeaderButton>
<IconReplace stroke={1.5} /> </Menu.Target>
</HeaderButton> <Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
</Menu.Target> <ScrollArea.Autosize mah={300}>
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}> {boards.map((board) => (
<ScrollArea.Autosize mah={300}> <Menu.Item
{boards.map((board) => ( key={board.id}
<Menu.Item component={Link}
key={board.id} href={`/boards/${board.name}`}
component={Link} leftSection={<IconLayoutBoard size={20} />}
href={`/boards/${board.name}`} >
leftSection={<IconLayoutBoard size={20} />} {board.name}
> </Menu.Item>
{board.name} ))}
</Menu.Item> </ScrollArea.Autosize>
))} </Menu.Dropdown>
</ScrollArea.Autosize> </Menu>
</Menu.Dropdown>
</Menu>
</Box>
</OnboardingTour.Target>
); );
}; };
@@ -264,7 +244,7 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
}; };
const handleBeforeUnload = (event: BeforeUnloadEvent) => { const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (env.NODE_ENV === "development") return; if (env.NODE_ENV === "development") return; // Allow to reload in development
event.preventDefault(); event.preventDefault();
event.returnValue = true; event.returnValue = true;
@@ -2,7 +2,7 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import type { MantineColorsTuple } from "@mantine/core"; import type { MantineColorsTuple } from "@mantine/core";
import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem, v8CssVariablesResolver } from "@mantine/core"; import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem } from "@mantine/core";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import type { ColorScheme } from "@homarr/definitions"; import type { ColorScheme } from "@homarr/definitions";
@@ -24,7 +24,6 @@ export const BoardMantineProvider = ({
}, },
primaryColor: "primaryColor", primaryColor: "primaryColor",
autoContrast: true, autoContrast: true,
defaultRadius: "sm",
fontSizes: { fontSizes: {
"2xl": rem(24), "2xl": rem(24),
"3xl": rem(28), "3xl": rem(28),
@@ -33,12 +32,7 @@ export const BoardMantineProvider = ({
}); });
return ( return (
<MantineProvider <MantineProvider defaultColorScheme={defaultColorScheme} theme={theme} colorSchemeManager={colorSchemeManager}>
defaultColorScheme={defaultColorScheme}
theme={theme}
colorSchemeManager={colorSchemeManager}
cssVariablesResolver={v8CssVariablesResolver}
>
{children} {children}
</MantineProvider> </MantineProvider>
); );
@@ -83,7 +83,7 @@ export const ColorSettingsContent = ({ board }: Props) => {
<Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor> <Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
</Grid.Col> </Grid.Col>
<Grid.Col span={12}> <Grid.Col span={12}>
<Collapse expanded={showPreview}> <Collapse in={showPreview}>
<Stack> <Stack>
<ColorsPreview previewColor={form.values.primaryColor} /> <ColorsPreview previewColor={form.values.primaryColor} />
<ColorsPreview previewColor={form.values.secondaryColor} /> <ColorsPreview previewColor={form.values.secondaryColor} />
@@ -11,7 +11,6 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { MainHeader } from "~/components/layout/header"; import { MainHeader } from "~/components/layout/header";
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo"; import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
import { ClientShell } from "~/components/layout/shell"; import { ClientShell } from "~/components/layout/shell";
import { BoardTourProvider } from "~/components/onboarding/board-tour";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme"; import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
import type { Board } from "./_types"; import type { Board } from "./_types";
import type { Params } from "./(content)/_creator"; import type { Params } from "./(content)/_creator";
@@ -21,21 +20,14 @@ import { BoardMantineProvider } from "./(content)/_theme";
const logger = createLogger({ module: "createBoardLayout" }); const logger = createLogger({ module: "createBoardLayout" });
const BoardTourWrapper = ({ hasSession, children }: PropsWithChildren<{ hasSession: boolean }>) => {
if (!hasSession) return <>{children}</>;
return <BoardTourProvider>{children}</BoardTourProvider>;
};
interface CreateBoardLayoutProps<TParams extends Params> { interface CreateBoardLayoutProps<TParams extends Params> {
headerActions: JSX.Element; headerActions: JSX.Element;
getInitialBoardAsync: (params: TParams) => Promise<Board>; getInitialBoardAsync: (params: TParams) => Promise<Board>;
withTour?: boolean;
} }
export const createBoardLayout = <TParams extends Params>({ export const createBoardLayout = <TParams extends Params>({
headerActions, headerActions,
getInitialBoardAsync: getInitialBoard, getInitialBoardAsync: getInitialBoard,
withTour = false,
}: CreateBoardLayoutProps<TParams>) => { }: CreateBoardLayoutProps<TParams>) => {
const Layout = async ({ const Layout = async ({
params, params,
@@ -69,16 +61,14 @@ export const createBoardLayout = <TParams extends Params>({
<EditModeProvider> <EditModeProvider>
<BoardMantineProvider defaultColorScheme={colorScheme}> <BoardMantineProvider defaultColorScheme={colorScheme}>
<CustomCss /> <CustomCss />
<BoardTourWrapper hasSession={withTour && !!session}> <ClientShell hasNavigation={false}>
<ClientShell hasNavigation={false}> <MainHeader
<MainHeader logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />} actions={headerActions}
actions={headerActions} hasNavigation={false}
hasNavigation={false} />
/> <AppShellMain>{children}</AppShellMain>
<AppShellMain>{children}</AppShellMain> </ClientShell>
</ClientShell>
</BoardTourWrapper>
</BoardMantineProvider> </BoardMantineProvider>
</EditModeProvider> </EditModeProvider>
</BoardReadyProvider> </BoardReadyProvider>
@@ -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,27 +1,39 @@
import type { MantineColor } from "@mantine/core"; import type { MantineColor } from "@mantine/core";
import { Button, Stack, Text } from "@mantine/core"; import { Button, Card, Stack, Text } from "@mantine/core";
import { IconBook2, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react"; import { IconBook2, IconCategoryPlus, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react";
import { isProviderEnabled } from "@homarr/auth/server"; import { isProviderEnabled } from "@homarr/auth/server";
import { getMantineColor } from "@homarr/common"; import { getMantineColor } from "@homarr/common";
import { db } from "@homarr/db";
import { createDocumentationLink } from "@homarr/definitions"; import { createDocumentationLink } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui"; import { Link } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui";
import { InitStepCard } from "../../_components/init-step-card";
export const InitFinish = async () => { export const InitFinish = async () => {
const firstBoard = await db.query.boards.findFirst({ columns: { name: true } });
const tFinish = await getScopedI18n("init.step.finish"); const tFinish = await getScopedI18n("init.step.finish");
return ( return (
<InitStepCard> <Card w={64 * 6} maw="90vw" withBorder>
<Stack> <Stack>
<Text>{tFinish("description")}</Text> <Text>{tFinish("description")}</Text>
<InternalLinkButton href="/auth/login?callbackUrl=/" iconProps={{ icon: IconLayoutDashboard, color: "blue" }}> {firstBoard ? (
{tFinish("action.goToBoard", { name: "dashboard" })} <InternalLinkButton
</InternalLinkButton> href={`/auth/login?callbackUrl=/boards/${firstBoard.name}`}
iconProps={{ icon: IconLayoutDashboard, color: "blue" }}
>
{tFinish("action.goToBoard", { name: firstBoard.name })}
</InternalLinkButton>
) : (
<InternalLinkButton
href="/auth/login?callbackUrl=/manage/boards"
iconProps={{ icon: IconCategoryPlus, color: "blue" }}
>
{tFinish("action.createBoard")}
</InternalLinkButton>
)}
{isProviderEnabled("credentials") && ( {isProviderEnabled("credentials") && (
<InternalLinkButton <InternalLinkButton
@@ -39,7 +51,7 @@ export const InitFinish = async () => {
{tFinish("action.docs")} {tFinish("action.docs")}
</ExternalLinkButton> </ExternalLinkButton>
</Stack> </Stack>
</InitStepCard> </Card>
); );
}; };
@@ -1,6 +1,6 @@
"use client"; "use client";
import { Button, Stack, TextInput } from "@mantine/core"; import { Button, Card, Stack, TextInput } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react"; import { IconArrowRight } from "@tabler/icons-react";
import type { z } from "zod/v4"; import type { z } from "zod/v4";
@@ -10,8 +10,6 @@ import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { groupCreateSchema } from "@homarr/validation/group"; import { groupCreateSchema } from "@homarr/validation/group";
import { InitStepCard } from "../../_components/init-step-card";
export const InitGroup = () => { export const InitGroup = () => {
const t = useI18n(); const t = useI18n();
const { mutateAsync } = clientApi.group.createInitialExternalGroup.useMutation({ const { mutateAsync } = clientApi.group.createInitialExternalGroup.useMutation({
@@ -36,7 +34,7 @@ export const InitGroup = () => {
}; };
return ( return (
<InitStepCard> <Card w={64 * 6} maw="90vw" withBorder>
<form onSubmit={form.onSubmit(handleSubmitAsync)}> <form onSubmit={form.onSubmit(handleSubmitAsync)}>
<Stack> <Stack>
<TextInput <TextInput
@@ -50,6 +48,6 @@ export const InitGroup = () => {
</Button> </Button>
</Stack> </Stack>
</form> </form>
</InitStepCard> </Card>
); );
}; };
@@ -1,12 +1,10 @@
import { ActionIcon, Button, Group, Text } from "@mantine/core"; import { ActionIcon, Button, Card, Group, Text } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone"; import type { FileWithPath } from "@mantine/dropzone";
import { IconPencil } from "@tabler/icons-react"; import { IconPencil } from "@tabler/icons-react";
import { humanFileSize } from "@homarr/common"; import { humanFileSize } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { InitStepCard } from "../../_components/init-step-card";
interface FileInfoCardProps { interface FileInfoCardProps {
file: FileWithPath; file: FileWithPath;
onRemove: () => void; onRemove: () => void;
@@ -15,7 +13,7 @@ interface FileInfoCardProps {
export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => { export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
const tFileInfo = useScopedI18n("init.step.import.fileInfo"); const tFileInfo = useScopedI18n("init.step.import.fileInfo");
return ( return (
<InitStepCard withBorder={false}> <Card w={64 * 12 + 8} maw="90vw">
<Group justify="space-between" align="center" wrap="nowrap"> <Group justify="space-between" align="center" wrap="nowrap">
<Group> <Group>
<Text fw={500} lineClamp={1} style={{ wordBreak: "break-all" }}> <Text fw={500} lineClamp={1} style={{ wordBreak: "break-all" }}>
@@ -38,6 +36,6 @@ export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
<IconPencil size={16} stroke={1.5} /> <IconPencil size={16} stroke={1.5} />
</ActionIcon> </ActionIcon>
</Group> </Group>
</InitStepCard> </Card>
); );
}; };
@@ -1,14 +1,13 @@
"use client"; "use client";
import { startTransition, useState } from "react"; import { startTransition, useState } from "react";
import { Stack } from "@mantine/core"; import { Card, Stack } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone"; import type { FileWithPath } from "@mantine/dropzone";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { InitialOldmarrImport } from "@homarr/old-import/components"; import { InitialOldmarrImport } from "@homarr/old-import/components";
import { InitStepCard } from "../../_components/init-step-card";
import { FileInfoCard } from "./file-info-card"; import { FileInfoCard } from "./file-info-card";
import { ImportDropZone } from "./import-dropzone"; import { ImportDropZone } from "./import-dropzone";
@@ -21,7 +20,7 @@ export const InitImport = () => {
if (!file) { if (!file) {
return ( return (
<InitStepCard> <Card w={64 * 12 + 8} maw="90vw" withBorder>
<ImportDropZone <ImportDropZone
loading={isPending} loading={isPending}
updateFile={(file) => { updateFile={(file) => {
@@ -41,12 +40,12 @@ export const InitImport = () => {
}); });
}} }}
/> />
</InitStepCard> </Card>
); );
} }
return ( return (
<Stack w="100%" mb="sm"> <Stack mb="sm">
<FileInfoCard file={file} onRemove={() => setFile(null)} /> <FileInfoCard file={file} onRemove={() => setFile(null)} />
{analyseResult !== null && <InitialOldmarrImport file={file} analyseResult={analyseResult} />} {analyseResult !== null && <InitialOldmarrImport file={file} analyseResult={analyseResult} />}
</Stack> </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,486 +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,6 +1,6 @@
"use client"; "use client";
import { Button, Group, Stack, Switch, Text } from "@mantine/core"; import { Button, Card, Group, Stack, Switch, Text } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react"; import { IconArrowRight } from "@tabler/icons-react";
import type { z } from "zod/v4"; import type { z } from "zod/v4";
@@ -13,8 +13,6 @@ import type { TranslationObject } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { settingsInitSchema } from "@homarr/validation/settings"; import { settingsInitSchema } from "@homarr/validation/settings";
import { InitStepCard } from "../../_components/init-step-card";
export const InitSettings = () => { export const InitSettings = () => {
const tSection = useScopedI18n("management.page.settings.section"); const tSection = useScopedI18n("management.page.settings.section");
const t = useI18n(); const t = useI18n();
@@ -31,8 +29,8 @@ export const InitSettings = () => {
return ( return (
<form onSubmit={form.onSubmit(handleSubmitAsync)}> <form onSubmit={form.onSubmit(handleSubmitAsync)}>
<Stack w="100%"> <Stack>
<InitStepCard> <Card w={64 * 12 + 8} maw="90vw" withBorder>
<Stack gap="sm"> <Stack gap="sm">
<Text fw={500}>{tSection("analytics.title")}</Text> <Text fw={500}>{tSection("analytics.title")}</Text>
@@ -40,8 +38,8 @@ export const InitSettings = () => {
<AnalyticsRow kind="general" {...form.getInputProps("analytics.enableGeneral", { type: "checkbox" })} /> <AnalyticsRow kind="general" {...form.getInputProps("analytics.enableGeneral", { type: "checkbox" })} />
</Stack> </Stack>
</Stack> </Stack>
</InitStepCard> </Card>
<InitStepCard> <Card w={64 * 12 + 8} maw="90vw" withBorder>
<Stack gap="sm"> <Stack gap="sm">
<Text fw={500}>{tSection("crawlingAndIndexing.title")}</Text> <Text fw={500}>{tSection("crawlingAndIndexing.title")}</Text>
@@ -64,7 +62,7 @@ export const InitSettings = () => {
/> />
</Stack> </Stack>
</Stack> </Stack>
</InitStepCard> </Card>
<Button type="submit" loading={form.submitting} rightSection={<IconArrowRight size={16} stroke={1.5} />}> <Button type="submit" loading={form.submitting} rightSection={<IconArrowRight size={16} stroke={1.5} />}>
{t("common.action.continue")} {t("common.action.continue")}
@@ -1,17 +1,16 @@
import { Stack, Text } from "@mantine/core"; import { Card, Stack, Text } from "@mantine/core";
import { IconFileImport, IconPlayerPlay } from "@tabler/icons-react"; import { IconFileImport, IconPlayerPlay } from "@tabler/icons-react";
import { getMantineColor } from "@homarr/common"; import { getMantineColor } from "@homarr/common";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { InitStepCard } from "../../_components/init-step-card";
import { InitStartButton } from "./next-button"; import { InitStartButton } from "./next-button";
export const InitStart = async () => { export const InitStart = async () => {
const tStart = await getScopedI18n("init.step.start"); const tStart = await getScopedI18n("init.step.start");
return ( return (
<InitStepCard> <Card w={64 * 6} maw="90vw" withBorder>
<Stack> <Stack>
<Text>{tStart("description")}</Text> <Text>{tStart("description")}</Text>
@@ -28,6 +27,6 @@ export const InitStart = async () => {
{tStart("action.importOldmarr")} {tStart("action.importOldmarr")}
</InitStartButton> </InitStartButton>
</Stack> </Stack>
</InitStepCard> </Card>
); );
}; };
@@ -1,15 +1,15 @@
"use client"; "use client";
import { Button, Stack, TextInput } from "@mantine/core"; import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
import type { z } from "zod/v4"; import type { z } from "zod/v4";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { signIn } from "@homarr/auth/client"; import { signIn } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client"; import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { UserCreatePasswordFields } from "@homarr/forms-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { CustomPasswordInput } from "@homarr/ui";
import { userInitSchema } from "@homarr/validation/user"; import { userInitSchema } from "@homarr/validation/user";
export const InitUserForm = () => { export const InitUserForm = () => {
@@ -59,10 +59,12 @@ export const InitUserForm = () => {
> >
<Stack gap="lg"> <Stack gap="lg">
<TextInput label={t("field.username.label")} {...form.getInputProps("username")} /> <TextInput label={t("field.username.label")} {...form.getInputProps("username")} />
<UserCreatePasswordFields <CustomPasswordInput
passwordInputProps={form.getInputProps("password")} withPasswordRequirements
confirmPasswordInputProps={form.getInputProps("confirmPassword")} label={t("field.password.label")}
{...form.getInputProps("password")}
/> />
<PasswordInput label={t("field.passwordConfirm.label")} {...form.getInputProps("confirmPassword")} />
<Button type="submit" fullWidth loading={isPending}> <Button type="submit" fullWidth loading={isPending}>
{t("action.create")} {t("action.create")}
</Button> </Button>
@@ -1,10 +1,11 @@
import { InitStepCard } from "../../_components/init-step-card"; import { Card } from "@mantine/core";
import { InitUserForm } from "./init-user-form"; import { InitUserForm } from "./init-user-form";
export const InitUser = () => { export const InitUser = () => {
return ( return (
<InitStepCard> <Card w={64 * 6} maw="90vw" withBorder>
<InitUserForm /> <InitUserForm />
</InitStepCard> </Card>
); );
}; };
+1 -4
View File
@@ -9,12 +9,10 @@ import { getScopedI18n } from "@homarr/translation/server";
import { CurrentColorSchemeCombobox } from "~/components/color-scheme/current-color-scheme-combobox"; import { CurrentColorSchemeCombobox } from "~/components/color-scheme/current-color-scheme-combobox";
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox"; import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo"; import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { onboardingContentWidth, onboardingMaxWidth } from "./_constants";
import { BackToStart } from "./_steps/back"; import { BackToStart } from "./_steps/back";
import { InitFinish } from "./_steps/finish/init-finish"; import { InitFinish } from "./_steps/finish/init-finish";
import { InitGroup } from "./_steps/group/init-group"; import { InitGroup } from "./_steps/group/init-group";
import { InitImport } from "./_steps/import/init-import"; import { InitImport } from "./_steps/import/init-import";
import { InitIntegrations } from "./_steps/integrations/init-integrations";
import { InitSettings } from "./_steps/settings/init-settings"; import { InitSettings } from "./_steps/settings/init-settings";
import { InitStart } from "./_steps/start/init-start"; import { InitStart } from "./_steps/start/init-start";
import { InitUser } from "./_steps/user/init-user"; import { InitUser } from "./_steps/user/init-user";
@@ -25,7 +23,6 @@ const stepComponents: Record<OnboardingStep, null | (() => MaybePromise<JSX.Elem
user: InitUser, user: InitUser,
group: InitGroup, group: InitGroup,
settings: InitSettings, settings: InitSettings,
integrations: InitIntegrations,
finish: InitFinish, finish: InitFinish,
}; };
@@ -38,7 +35,7 @@ export default async function InitPage() {
return ( return (
<Box mih="100dvh"> <Box mih="100dvh">
<Center> <Center>
<Stack align="center" mt="xl" w={onboardingContentWidth} maw={onboardingMaxWidth}> <Stack align="center" mt="xl">
<HomarrLogoWithTitle size="lg" /> <HomarrLogoWithTitle size="lg" />
<Stack gap={6} align="center"> <Stack gap={6} align="center">
<Title order={3} fw={400} ta="center"> <Title order={3} fw={400} ta="center">
+4 -5
View File
@@ -1,7 +1,6 @@
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google"; import { Inter } from "next/font/google";
import "@gfazioli/mantine-onboarding-tour/styles.css";
import "@homarr/notifications/styles.css"; import "@homarr/notifications/styles.css";
import "@homarr/spotlight/styles.css"; import "@homarr/spotlight/styles.css";
import "@homarr/ui/styles.css"; import "@homarr/ui/styles.css";
@@ -45,13 +44,13 @@ const fontSans = Inter({
export const generateMetadata = async (): Promise<Metadata> => ({ export const generateMetadata = async (): Promise<Metadata> => ({
title: "Homarr", title: "Homarr",
description: description:
"A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.", "Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.",
openGraph: { openGraph: {
title: "Homarr Dashboard", title: "Homarr Dashboard",
description: description:
"A self-hosted dashboard for the *arr stack and your entire homelab. Integrates with 50+ services, real-time widgets, no config files.", "Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips.",
url: "https://homarr.dev", url: "https://homarr.dev",
siteName: "Homarr", siteName: "Homarr Documentation",
}, },
icons: { icons: {
icon: "/logo/logo.png", icon: "/logo/logo.png",
@@ -143,7 +142,7 @@ export default async function Layout(props: {
</head> </head>
<body className={["font-sans", fontSans.variable].join(" ")}> <body className={["font-sans", fontSans.variable].join(" ")}>
<StackedProvider> <StackedProvider>
<Notifications pauseResetOnHover="notification" /> <Notifications />
<ServiceWorkerRegistration /> <ServiceWorkerRegistration />
{props.children} {props.children}
</StackedProvider> </StackedProvider>
@@ -45,6 +45,7 @@ export const HeroBanner = async () => {
<Image <Image
key={`grid-column-${columnIndex}-scroll-1-${index}`} key={`grid-column-${columnIndex}-scroll-1-${index}`}
src={icon} src={icon}
radius="md"
fit={"contain"} fit={"contain"}
w={50} w={50}
h={50} h={50}
@@ -56,6 +57,7 @@ export const HeroBanner = async () => {
<Image <Image
key={`grid-column-${columnIndex}-scroll-2-${index}`} key={`grid-column-${columnIndex}-scroll-2-${index}`}
src={icon} src={icon}
radius="md"
fit={"contain"} fit={"contain"}
w={50} w={50}
h={50} h={50}
@@ -90,7 +90,9 @@ export default async function AboutPage() {
<Group py="lg"> <Group py="lg">
<Image src={homarrLogoPath} width={100} height={100} alt="" /> <Image src={homarrLogoPath} width={100} height={100} alt="" />
<Stack gap={0}> <Stack gap={0}>
<Title order={1}>Homarr</Title> <Title order={1} tt="uppercase">
Homarr
</Title>
<Title order={2}>{t("version", { version })}</Title> <Title order={2}>{t("version", { version })}</Title>
</Stack> </Stack>
</Group> </Group>
@@ -193,9 +195,9 @@ export default async function AboutPage() {
href={`https://www.npmjs.com/package/${name}`} href={`https://www.npmjs.com/package/${name}`}
target="_blank" target="_blank"
> >
<Card radius="md" p="xs" className={classes.dependencyCard}> <Card withBorder radius="md" p="xs" className={classes.dependencyCard}>
<Group gap="sm" wrap="nowrap"> <Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" size="lg"> <ThemeIcon variant="light" size="lg" radius="md">
<IconPackage size="1.5rem" stroke={1.5} /> <IconPackage size="1.5rem" stroke={1.5} />
</ThemeIcon> </ThemeIcon>
@@ -1,6 +1,6 @@
import { Fragment } from "react"; import { Fragment } from "react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core"; import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
import { IconBox, IconPencil } from "@tabler/icons-react"; import { IconBox, IconPencil } from "@tabler/icons-react";
import { z } from "zod/v4"; import { z } from "zod/v4";
@@ -11,9 +11,9 @@ import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { getI18n, getScopedI18n } from "@homarr/translation/server"; import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { Link, SearchInput, TablePagination } from "@homarr/ui"; import { Link, SearchInput, TablePagination } from "@homarr/ui";
import { TourTarget } from "~/components/layout/header/tour-target"; import { ManageContainer } from "~/components/manage/manage-container";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results"; import { NoResults } from "~/components/no-results";
import { AppDeleteButton } from "./_app-delete-button"; import { AppDeleteButton } from "./_app-delete-button";
@@ -39,35 +39,34 @@ export default async function AppsPage(props: AppsPageProps) {
const { items: apps, totalCount } = await api.app.getPaginated(searchParams); const { items: apps, totalCount } = await api.app.getPaginated(searchParams);
const t = await getScopedI18n("app"); const t = await getScopedI18n("app");
const canCreate = session.user.permissions.includes("app-create");
return ( return (
<ManagePageLayout <ManageContainer>
title={t("page.list.title")} <DynamicBreadcrumb />
primaryAction={ <Stack>
canCreate ? ( <Title>{t("page.list.title")}</Title>
<TourTarget id="manage-apps-create"> <Group justify="space-between" align="center">
<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand />
{session.user.permissions.includes("app-create") && (
<MobileAffixButton component={Link} href="/manage/apps/new"> <MobileAffixButton component={Link} href="/manage/apps/new">
{t("page.create.title")} {t("page.create.title")}
</MobileAffixButton> </MobileAffixButton>
</TourTarget> )}
) : undefined </Group>
} {apps.length === 0 && <AppNoResults />}
toolbar={<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand />} {apps.length > 0 && (
footer={<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />}
floatingPrimaryAction={canCreate}
>
{apps.length === 0 && <AppNoResults />}
{apps.length > 0 && (
<TourTarget id="manage-apps-list">
<Stack gap="sm"> <Stack gap="sm">
{apps.map((app) => ( {apps.map((app) => (
<AppCard key={app.id} app={app} /> <AppCard key={app.id} app={app} />
))} ))}
</Stack> </Stack>
</TourTarget> )}
)}
</ManagePageLayout> {/* Added margin to not hide pagination behind affix-button */}
<Group justify="end" mb={48}>
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
</Group>
</Stack>
</ManageContainer>
); );
} }
@@ -80,7 +79,7 @@ const AppCard = async ({ app }: AppCardProps) => {
const session = await auth(); const session = await auth();
return ( return (
<Card> <Card withBorder>
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Group align="top" justify="start" wrap="nowrap" style={{ flex: "1" }}> <Group align="top" justify="start" wrap="nowrap" style={{ flex: "1" }}>
<Avatar <Avatar
@@ -1,38 +1,43 @@
"use client"; "use client";
import { Button, Menu } from "@mantine/core"; import { Affix, Button, Menu } from "@mantine/core";
import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react"; import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection"; import { AddBoardModal, ImportBoardModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
export const CreateBoardButton = () => { export const CreateBoardButton = () => {
const t = useI18n(); const t = useI18n();
const { openModal: openAddModal } = useModalAction(AddBoardModal); const { openModal: openAddModal } = useModalAction(AddBoardModal);
const { openModal: openImportModal } = useModalAction(ImportBoardModal); const { openModal: openImportModal } = useModalAction(ImportBoardModal);
const buttonGroupContent = (
<>
<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>
</>
);
return ( return (
<ManageMobilePrimaryAction> <>
<Button.Group> <Button.Group visibleFrom="md">{buttonGroupContent}</Button.Group>
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={openAddModal}> <Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}>
{t("management.page.board.action.new.label")} <Button.Group>{buttonGroupContent}</Button.Group>
</Button> </Affix>
<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>
</ManageMobilePrimaryAction>
); );
}; };
@@ -9,17 +9,12 @@ import {
Group, Group,
Menu, Menu,
MenuTarget, MenuTarget,
Stack,
Text, Text,
Title,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { import { IconDeviceMobile, IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/icons-react";
IconDeviceMobile,
IconDotsVertical,
IconHomeFilled,
IconLayoutDashboard,
IconLock,
IconWorld,
} from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
@@ -28,9 +23,8 @@ import { getScopedI18n } from "@homarr/translation/server";
import { Link, UserAvatar } from "@homarr/ui"; import { Link, UserAvatar } from "@homarr/ui";
import { getBoardPermissionsAsync } from "~/components/board/permissions/server"; import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
import { TourTarget } from "~/components/layout/header/tour-target"; import { ManageContainer } from "~/components/manage/manage-container";
import { ManagePageLayout } from "~/components/manage/manage-page-layout"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results";
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown"; import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
import { CreateBoardButton } from "./_components/create-board-button"; import { CreateBoardButton } from "./_components/create-board-button";
@@ -41,30 +35,23 @@ export default async function ManageBoardsPage() {
const canCreateBoards = session?.user.permissions.includes("board-create"); const canCreateBoards = session?.user.permissions.includes("board-create");
return ( return (
<ManagePageLayout <ManageContainer>
title={t("title")} <DynamicBreadcrumb />
primaryAction={ <Stack>
canCreateBoards ? ( <Group justify="space-between">
<TourTarget id="manage-boards-create"> <Title mb="md">{t("title")}</Title>
<CreateBoardButton /> {canCreateBoards && <CreateBoardButton />}
</TourTarget> </Group>
) : undefined
} <Grid mb={{ base: "xl", md: 0 }}>
floatingPrimaryAction={canCreateBoards} {boards.map((board) => (
> <GridCol span={{ base: 12, md: 6 }} key={board.id}>
{boards.length === 0 && <NoResults icon={IconLayoutDashboard} title={t("noResults.title")} />} <BoardCard board={board} />
{boards.length > 0 && ( </GridCol>
<TourTarget id="manage-boards-list"> ))}
<Grid> </Grid>
{boards.map((board) => ( </Stack>
<GridCol span={{ base: 12, md: 6 }} key={board.id}> </ManageContainer>
<BoardCard board={board} />
</GridCol>
))}
</Grid>
</TourTarget>
)}
</ManagePageLayout>
); );
} }
@@ -79,14 +66,16 @@ const BoardCard = async ({ board }: BoardCardProps) => {
const VisibilityIcon = board.isPublic ? IconWorld : IconLock; const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
return ( return (
<Card> <Card radius="lg" withBorder>
<CardSection p="sm" withBorder> <CardSection p="sm" withBorder>
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Group gap="sm"> <Group gap="sm">
<Tooltip label={t(`visibility.${visibility}`)}> <Tooltip label={t(`visibility.${visibility}`)}>
<VisibilityIcon size={20} stroke={1.5} /> <VisibilityIcon size={20} stroke={1.5} />
</Tooltip> </Tooltip>
<Text fw={700}>{board.name}</Text> <Text fw="bolder" tt="uppercase">
{board.name}
</Text>
</Group> </Group>
<Group> <Group>
@@ -134,7 +123,6 @@ const BoardCard = async ({ board }: BoardCardProps) => {
style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }} style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }}
variant="default" variant="default"
size="lg" size="lg"
aria-label={t("action.settings.label")}
> >
<IconDotsVertical size={16} stroke={1.5} /> <IconDotsVertical size={16} stroke={1.5} />
</ActionIcon> </ActionIcon>
@@ -33,7 +33,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
const KindIcon = integrationSecretIcons[secret.kind]; const KindIcon = integrationSecretIcons[secret.kind];
return ( return (
<Card> <Card withBorder>
<Stack> <Stack>
<Group justify="space-between"> <Group justify="space-between">
<Group> <Group>
@@ -58,12 +58,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
</Tooltip> </Tooltip>
)} )}
{isPublic ? ( {isPublic ? (
<ActionIcon <ActionIcon color="gray" variant="subtle" onClick={togglePublicSecretDisplay}>
color="gray"
variant="subtle"
onClick={togglePublicSecretDisplay}
aria-label={t(publicSecretDisplayOpened ? "common.action.hide" : "common.action.show")}
>
<DisplayIcon size={16} stroke={1.5} /> <DisplayIcon size={16} stroke={1.5} />
</ActionIcon> </ActionIcon>
) : null} ) : null}
@@ -84,7 +79,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
</Button> </Button>
</Group> </Group>
</Group> </Group>
<Collapse expanded={editMode}>{children}</Collapse> <Collapse in={editMode}>{children}</Collapse>
</Stack> </Stack>
</Card> </Card>
); );
@@ -12,7 +12,7 @@ interface FormType {
interface SecretKindsSegmentedControlProps<TFormType extends FormType> { interface SecretKindsSegmentedControlProps<TFormType extends FormType> {
defaultKinds?: IntegrationSecretKind[]; defaultKinds?: IntegrationSecretKind[];
secretKinds: IntegrationSecretKind[][]; secretKinds: IntegrationSecretKind[][];
form: UseFormReturnType<TFormType, TFormType>; form: UseFormReturnType<TFormType, (values: TFormType) => TFormType>;
} }
export const SecretKindsSegmentedControl = <TFormType extends FormType>({ export const SecretKindsSegmentedControl = <TFormType extends FormType>({
@@ -18,7 +18,7 @@ export const IntegrationTestConnectionError = ({ error, url }: IntegrationTestCo
const causeArray = useMemo(() => toCauseArray(error.cause), [error.cause]); const causeArray = useMemo(() => toCauseArray(error.cause), [error.cause]);
return ( return (
<Card style={{ borderColor: getMantineColor("red", 8) }}> <Card withBorder style={{ borderColor: getMantineColor("red", 8) }}>
<Stack> <Stack>
<Stack gap="sm"> <Stack gap="sm">
<Text size="lg" fw={500} c="red.8"> <Text size="lg" fw={500} c="red.8">
@@ -216,7 +216,7 @@ export const CertificateDetailsCard = ({ certificate }: CertificateDetailsProps)
const tCertificateField = useScopedI18n("certificate.field"); const tCertificateField = useScopedI18n("certificate.field");
return ( return (
<Card> <Card withBorder>
<Text fw={500}>{tDetails("title")}</Text> <Text fw={500}>{tDetails("title")}</Text>
<Group justify="space-between"> <Group justify="space-between">
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
@@ -0,0 +1,59 @@
"use client";
import type { ChangeEvent } from "react";
import React, { useMemo, useState } from "react";
import { Flex, Group, Menu, ScrollArea, Text, TextInput } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar, Link } from "@homarr/ui";
interface IntegrationCreateDropdownContentProps {
enableMockIntegration: boolean;
}
export const IntegrationCreateDropdownContent = ({ enableMockIntegration }: IntegrationCreateDropdownContentProps) => {
const t = useI18n();
const [search, setSearch] = useState("");
const filteredKinds = useMemo(() => {
return integrationKinds
.filter((kind) => enableMockIntegration || kind !== "mock")
.filter((kind) => getIntegrationName(kind).toLowerCase().includes(search.toLowerCase().trim()))
.sort((kindA, kindB) => getIntegrationName(kindA).localeCompare(getIntegrationName(kindB)));
}, [search, enableMockIntegration]);
const handleSearch = React.useCallback(
(event: ChangeEvent<HTMLInputElement>) => setSearch(event.target.value),
[setSearch],
);
return (
<Flex direction={{ base: "column-reverse", md: "column" }} gap="sm">
<TextInput
leftSection={<IconSearch stroke={1.5} size={20} />}
placeholder={t("integration.page.list.search")}
value={search}
data-autofocus
onChange={handleSearch}
variant="filled"
/>
{filteredKinds.length > 0 ? (
<ScrollArea.Autosize mah={384}>
{filteredKinds.map((kind) => (
<Menu.Item component={Link} href={`/manage/integrations/new?kind=${kind}`} key={kind}>
<Group>
<IntegrationAvatar kind={kind} size="sm" />
<Text size="sm">{getIntegrationName(kind)}</Text>
</Group>
</Menu.Item>
))}
</ScrollArea.Autosize>
) : (
<Menu.Item disabled>{t("common.noResults")}</Menu.Item>
)}
</Flex>
);
};
@@ -1,26 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import type { IntegrationKind } from "@homarr/definitions";
import { NewIntegrationForm } from "./_integration-new-form";
interface IntegrationNewFormWrapperProps {
kind: IntegrationKind;
initialUrl?: string;
initialName?: string;
}
export const IntegrationNewFormWrapper = ({ kind, initialUrl, initialName }: IntegrationNewFormWrapperProps) => {
const router = useRouter();
return (
<NewIntegrationForm
kind={kind}
initialUrl={initialUrl}
initialName={initialName}
onSuccess={() => router.push("/manage/integrations")}
/>
);
};
@@ -1,9 +1,9 @@
"use client"; "use client";
import { startTransition, useState } from "react"; import { startTransition, useState } from "react";
import { useRouter } from "next/navigation";
import { import {
Alert, Alert,
Anchor,
Button, Button,
Checkbox, Checkbox,
Collapse, Collapse,
@@ -16,7 +16,7 @@ import {
Text, Text,
TextInput, TextInput,
} from "@mantine/core"; } from "@mantine/core";
import { IconCheck, IconExternalLink, IconInfoCircle, IconKey } from "@tabler/icons-react"; import { IconCheck, IconInfoCircle } from "@tabler/icons-react";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
@@ -27,7 +27,6 @@ import type { IntegrationKind } from "@homarr/definitions";
import { import {
getAllSecretKindOptions, getAllSecretKindOptions,
getIconUrl, getIconUrl,
getIntegrationApiKeyUrl,
getIntegrationDefaultUrl, getIntegrationDefaultUrl,
getIntegrationName, getIntegrationName,
integrationDefs, integrationDefs,
@@ -46,13 +45,9 @@ import { IntegrationTestConnectionError } from "../_components/test-connection/i
import type { AnyMappedTestConnectionError } from "../_components/test-connection/types"; import type { AnyMappedTestConnectionError } from "../_components/test-connection/types";
interface NewIntegrationFormProps { interface NewIntegrationFormProps {
kind: IntegrationKind; searchParams: Partial<z.infer<typeof integrationCreateSchema>> & {
initialUrl?: string; kind: IntegrationKind;
initialName?: string; };
onSuccess: () => void;
onCancel?: () => void;
onSkip?: () => void;
isOnboarding?: boolean;
} }
const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and( const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and(
@@ -63,48 +58,39 @@ const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and(
}), }),
); );
export const NewIntegrationForm = ({ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => {
kind,
initialUrl,
initialName,
onSuccess,
onCancel,
onSkip,
isOnboarding = false,
}: NewIntegrationFormProps) => {
const t = useI18n(); const t = useI18n();
const secretKinds = getAllSecretKindOptions(kind); const secretKinds = getAllSecretKindOptions(searchParams.kind);
const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url")); const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url"));
const router = useRouter();
const { data: session } = useSession(); const { data: session } = useSession();
const canCreateApps = !isOnboarding && (session?.user.permissions.includes("app-create") ?? false); const canCreateApps = session?.user.permissions.includes("app-create") ?? false;
let url = initialUrl ?? getIntegrationDefaultUrl(kind) ?? ""; let url = searchParams.url ?? getIntegrationDefaultUrl(searchParams.kind) ?? "";
if (hasUrlSecret) { if (hasUrlSecret) {
// Placeholder Url, replaced with origin of the secret Url on submit
url = "http://localhost"; url = "http://localhost";
} }
const form = useZodForm(formSchema, { const form = useZodForm(formSchema, {
initialValues: { initialValues: {
name: initialName ?? getIntegrationName(kind), name: searchParams.name ?? getIntegrationName(searchParams.kind),
url, url,
secrets: secretKinds[0].map((kind) => ({ secrets: secretKinds[0].map((kind) => ({
kind, kind,
value: "", value: "",
})), })),
attemptSearchEngineCreation: !isOnboarding, attemptSearchEngineCreation: true,
hasApp: !isOnboarding, hasApp: false,
appHref: isOnboarding ? null : url, appHref: url,
appId: null, appId: null,
}, },
}); });
const { mutateAsync: createIntegrationAsync, isPending: isCreatePending } = clientApi.integration.create.useMutation({ const { mutateAsync: createIntegrationAsync, isPending } = clientApi.integration.create.useMutation({
async onSuccess() { async onSuccess() {
await revalidatePathActionAsync("/manage/integrations"); await revalidatePathActionAsync("/manage/integrations");
}, },
}); });
const { mutateAsync: createOnboardingIntegrationAsync, isPending: isOnboardingCreatePending } =
clientApi.onboard.createIntegration.useMutation();
const isPending = isCreatePending || isOnboardingCreatePending;
const [error, setError] = useState<null | AnyMappedTestConnectionError>(null); const [error, setError] = useState<null | AnyMappedTestConnectionError>(null);
const handleSubmitAsync = async ({ appId, appHref, hasApp, ...values }: FormType) => { const handleSubmitAsync = async ({ appId, appHref, hasApp, ...values }: FormType) => {
@@ -112,39 +98,6 @@ export const NewIntegrationForm = ({
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin ? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
: values.url; : values.url;
const onMutationSuccess = (data: { error?: AnyMappedTestConnectionError } | undefined | void) => {
if (data && "error" in data && data.error) {
setError(data.error);
showErrorNotification({
title: t("integration.page.create.notification.error.title"),
message: t("integration.page.create.notification.error.message"),
});
return;
}
showSuccessNotification({
title: t("integration.page.create.notification.success.title"),
message: t("integration.page.create.notification.success.message"),
});
onSuccess();
};
const onMutationError = () => {
showErrorNotification({
title: t("integration.page.create.notification.error.title"),
message: t("integration.page.create.notification.error.message"),
});
};
if (isOnboarding) {
await createOnboardingIntegrationAsync(
{ kind, name: values.name, url, secrets: values.secrets },
{ onSuccess: onMutationSuccess, onError: onMutationError },
);
return;
}
const hasCustomHref = appHref !== null && appHref.trim().length >= 1; const hasCustomHref = appHref !== null && appHref.trim().length >= 1;
const app = hasApp const app = hasApp
@@ -153,21 +106,49 @@ export const NewIntegrationForm = ({
: { : {
name: values.name, name: values.name,
href: hasCustomHref ? appHref : url, href: hasCustomHref ? appHref : url,
iconUrl: getIconUrl(kind), iconUrl: getIconUrl(searchParams.kind),
description: null, description: null,
pingUrl: url, pingUrl: url,
} }
: undefined; : undefined;
await createIntegrationAsync( await createIntegrationAsync(
{ kind, ...values, url, app }, {
{ onSuccess: onMutationSuccess, onError: onMutationError }, kind: searchParams.kind,
...values,
url,
app,
},
{
onSuccess(data) {
// We do it this way as we are unable to send a typesafe error through onError
if (data?.error) {
setError(data.error);
showErrorNotification({
title: t("integration.page.create.notification.error.title"),
message: t("integration.page.create.notification.error.message"),
});
return;
}
showSuccessNotification({
title: t("integration.page.create.notification.success.title"),
message: t("integration.page.create.notification.success.message"),
});
router.push("/manage/integrations");
},
onError: () => {
showErrorNotification({
title: t("integration.page.create.notification.error.title"),
message: t("integration.page.create.notification.error.message"),
});
},
},
); );
}; };
const integrationCategories = integrationDefs[kind].category.flat(); const supportsSearchEngine = integrationDefs[searchParams.kind].category.flat().includes("search");
const supportsSearchEngine =
integrationCategories.includes("search") && !integrationCategories.includes("mediaSearch");
return ( return (
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}> <form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
@@ -194,39 +175,27 @@ export const NewIntegrationForm = ({
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text> <Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text>
</Alert> </Alert>
)} )}
<ApiKeySettingsLink kind={kind} url={form.values.url} />
</Stack> </Stack>
</Fieldset> </Fieldset>
{error !== null && <IntegrationTestConnectionError error={error} url={form.values.url} />} {error !== null && <IntegrationTestConnectionError error={error} url={form.values.url} />}
{!isOnboarding && supportsSearchEngine && ( {supportsSearchEngine && (
<Checkbox <Checkbox
label={t("integration.field.attemptSearchEngineCreation.label")} label={t("integration.field.attemptSearchEngineCreation.label")}
description={t("integration.field.attemptSearchEngineCreation.description", { description={t("integration.field.attemptSearchEngineCreation.description", {
kind: getIntegrationName(kind), kind: getIntegrationName(searchParams.kind),
})} })}
{...form.getInputProps("attemptSearchEngineCreation", { type: "checkbox" })} {...form.getInputProps("attemptSearchEngineCreation", { type: "checkbox" })}
/> />
)} )}
{!isOnboarding && <AppForm form={form} canCreateApps={canCreateApps} />} <AppForm form={form} canCreateApps={canCreateApps} />
<Group justify="end" align="center"> <Group justify="end" align="center">
{onCancel ? ( <Button variant="default" component={Link} href="/manage/integrations">
<Button variant="default" onClick={onCancel}> {t("common.action.backToOverview")}
{t("common.action.backToOverview")} </Button>
</Button>
) : (
<Button variant="default" component={Link} href="/manage/integrations">
{t("common.action.backToOverview")}
</Button>
)}
{onSkip && (
<Button variant="default" onClick={onSkip}>
{t("common.action.skip")}
</Button>
)}
<Button type="submit" loading={isPending}> <Button type="submit" loading={isPending}>
{t("integration.testConnection.action.create")} {t("integration.testConnection.action.create")}
</Button> </Button>
@@ -257,7 +226,7 @@ const AppForm = ({ form, canCreateApps }: { form: UseFormReturnType<FormType>; c
description={t("integration.field.createApp.description")} description={t("integration.field.createApp.description")}
/> />
<Collapse expanded={form.values.hasApp}> <Collapse in={form.values.hasApp}>
<Fieldset legend={t("integration.field.app.sectionTitle")}> <Fieldset legend={t("integration.field.app.sectionTitle")}>
<Stack gap="sm"> <Stack gap="sm">
{canCreateApps && ( {canCreateApps && (
@@ -297,40 +266,6 @@ const AppForm = ({ form, canCreateApps }: { form: UseFormReturnType<FormType>; c
); );
}; };
const normalizeUrl = (raw: string): string | null => {
try {
return new URL(raw).href;
} catch {
// Bare IP / hostname — prepend http:// and retry
}
try {
return new URL(`http://${raw}`).href;
} catch {
return null;
}
};
const ApiKeySettingsLink = ({ kind, url }: { kind: IntegrationKind; url: string }) => {
const t = useI18n();
const apiKeyUrl = getIntegrationApiKeyUrl(url, kind);
if (!apiKeyUrl) return null;
const resolved = normalizeUrl(url);
if (!resolved) return null;
const fullApiKeyUrl = getIntegrationApiKeyUrl(resolved, kind);
return (
<Anchor href={fullApiKeyUrl ?? apiKeyUrl} target="_blank" rel="noopener noreferrer" size="sm">
<Group gap={4}>
<IconKey size={14} stroke={1.5} />
<Text size="sm">{t("integration.field.apiKeySettings.label")}</Text>
<IconExternalLink size={14} stroke={1.5} />
</Group>
</Anchor>
);
};
type IntegrationAppSelectProps = Modify< type IntegrationAppSelectProps = Modify<
GetInputPropsReturnType, GetInputPropsReturnType,
{ {
@@ -1,21 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import type { IntegrationKind } from "@homarr/definitions";
import { IntegrationSelectGrid } from "~/components/integration/integration-select-grid";
interface IntegrationNewSelectGridProps {
enableMockIntegration: boolean;
}
export const IntegrationNewSelectGrid = ({ enableMockIntegration }: IntegrationNewSelectGridProps) => {
const router = useRouter();
const handleSelect = (kind: IntegrationKind) => {
router.push(`/manage/integrations/new?kind=${kind}`);
};
return <IntegrationSelectGrid onSelect={handleSelect} enableMockIntegration={enableMockIntegration} />;
};
@@ -3,21 +3,21 @@ import { Container, Group, Stack, Title } from "@mantine/core";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import type { IntegrationKind } from "@homarr/definitions";
import { getIntegrationName, integrationKinds } from "@homarr/definitions"; import { getIntegrationName, integrationKinds } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { IntegrationAvatar } from "@homarr/ui"; import { IntegrationAvatar } from "@homarr/ui";
import type { integrationCreateSchema } from "@homarr/validation/integration";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { env } from "~/env"; import { NewIntegrationForm } from "./_integration-new-form";
import { IntegrationNewFormWrapper } from "./_integration-new-form-wrapper";
import { IntegrationNewSelectGrid } from "./_integration-new-select-grid";
interface NewIntegrationPageProps { interface NewIntegrationPageProps {
searchParams: Promise<{ searchParams: Promise<
kind?: string; Partial<z.infer<typeof integrationCreateSchema>> & {
url?: string; kind: IntegrationKind;
name?: string; }
}>; >;
} }
export default async function IntegrationsNewPage(props: NewIntegrationPageProps) { export default async function IntegrationsNewPage(props: NewIntegrationPageProps) {
@@ -28,20 +28,8 @@ export default async function IntegrationsNewPage(props: NewIntegrationPageProps
} }
const result = z.enum(integrationKinds).safeParse(searchParams.kind); const result = z.enum(integrationKinds).safeParse(searchParams.kind);
if (!result.success) { if (!result.success) {
const t = await getScopedI18n("integration"); notFound();
return (
<>
<DynamicBreadcrumb />
<Container>
<Stack>
<Title>{t("action.create")}</Title>
<IntegrationNewSelectGrid enableMockIntegration={env.UNSAFE_ENABLE_MOCK_INTEGRATION} />
</Stack>
</Container>
</>
);
} }
const tCreate = await getScopedI18n("integration.page.create"); const tCreate = await getScopedI18n("integration.page.create");
@@ -57,7 +45,7 @@ export default async function IntegrationsNewPage(props: NewIntegrationPageProps
<IntegrationAvatar kind={currentKind} size="md" /> <IntegrationAvatar kind={currentKind} size="md" />
<Title>{tCreate("title", { name: getIntegrationName(currentKind) })}</Title> <Title>{tCreate("title", { name: getIntegrationName(currentKind) })}</Title>
</Group> </Group>
<IntegrationNewFormWrapper kind={currentKind} initialUrl={searchParams.url} initialName={searchParams.name} /> <NewIntegrationForm searchParams={searchParams} />
</Stack> </Stack>
</Container> </Container>
</> </>
@@ -8,7 +8,7 @@
border: 1px solid transparent; border: 1px solid transparent;
position: relative; position: relative;
z-index: 0; z-index: 0;
transition: background-color 150ms ease; transition: transform 150ms ease;
overflow: hidden; overflow: hidden;
&[data-first="true"] { &[data-first="true"] {
@@ -20,9 +20,11 @@
} }
&[data-active] { &[data-active] {
transform: scale(1.01);
z-index: 1; z-index: 1;
background-color: var(--mantine-color-body); background-color: var(--mantine-color-body);
border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4)); border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
box-shadow: var(--mantine-shadow-md);
border-radius: var(--mantine-radius-lg); border-radius: var(--mantine-radius-lg);
} }
} }
@@ -1,4 +1,5 @@
import { Fragment } from "react"; import { Fragment } from "react";
import type { PropsWithChildren } from "react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { import {
AccordionControl, AccordionControl,
@@ -6,9 +7,15 @@ import {
AccordionPanel, AccordionPanel,
ActionIcon, ActionIcon,
ActionIconGroup, ActionIconGroup,
Affix,
Anchor, Anchor,
Box,
Button,
Divider, Divider,
Group, Group,
Menu,
MenuDropdown,
MenuTarget,
Stack, Stack,
Table, Table,
TableTbody, TableTbody,
@@ -17,8 +24,9 @@ import {
TableThead, TableThead,
TableTr, TableTr,
Text, Text,
Title,
} from "@mantine/core"; } from "@mantine/core";
import { IconPencil, IconPlugX } from "@tabler/icons-react"; import { IconChevronDown, IconChevronUp, IconPencil, IconPlugX } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
@@ -29,12 +37,13 @@ import { getIntegrationName } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui"; import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui";
import { TourTarget } from "~/components/layout/header/tour-target"; import { ManageContainer } from "~/components/manage/manage-container";
import { ManagePageLayout } from "~/components/manage/manage-page-layout"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { NoResults } from "~/components/no-results"; import { NoResults } from "~/components/no-results";
import { env } from "~/env";
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion"; import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
import { DeleteIntegrationActionButton } from "./_integration-buttons"; import { DeleteIntegrationActionButton } from "./_integration-buttons";
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
import classes from "./page.module.css"; import classes from "./page.module.css";
interface IntegrationsPageProps { interface IntegrationsPageProps {
@@ -57,26 +66,60 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
const canCreateIntegrations = session.user.permissions.includes("integration-create"); const canCreateIntegrations = session.user.permissions.includes("integration-create");
return ( return (
<ManagePageLayout <ManageContainer>
title={t("page.list.title")} <DynamicBreadcrumb />
primaryAction={ <Stack>
canCreateIntegrations ? ( <Group justify="space-between" align="center">
<TourTarget id="manage-integrations-create"> <Title>{t("page.list.title")}</Title>
<MobileAffixButton component={Link} href="/manage/integrations/new">
{t("action.create")} {canCreateIntegrations && (
</MobileAffixButton> <>
</TourTarget> <Box>
) : undefined <IntegrationSelectMenu>
} <Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}>
floatingPrimaryAction={canCreateIntegrations} <MenuTarget>
> <Button rightSection={<IconChevronUp size={16} stroke={1.5} />}>{t("action.create")}</Button>
<TourTarget id="manage-integrations-list"> </MenuTarget>
</Affix>
</IntegrationSelectMenu>
</Box>
<Box visibleFrom="md">
<IntegrationSelectMenu>
<MenuTarget>
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>{t("action.create")}</Button>
</MenuTarget>
</IntegrationSelectMenu>
</Box>
</>
)}
</Group>
<IntegrationList integrations={integrations} activeTab={searchParams.tab} /> <IntegrationList integrations={integrations} activeTab={searchParams.tab} />
</TourTarget> </Stack>
</ManagePageLayout> </ManageContainer>
); );
} }
const IntegrationSelectMenu = ({ children }: PropsWithChildren) => {
return (
<Menu
width={256}
trapFocus
position="bottom-end"
withinPortal
shadow="md"
keepMounted={false}
withInitialFocusPlaceholder={false}
>
{children}
<MenuDropdown>
<IntegrationCreateDropdownContent enableMockIntegration={env.UNSAFE_ENABLE_MOCK_INTEGRATION} />
</MenuDropdown>
</Menu>
);
};
interface IntegrationListProps { interface IntegrationListProps {
integrations: RouterOutputs["integration"]["all"]; integrations: RouterOutputs["integration"]["all"];
activeTab?: IntegrationKind; activeTab?: IntegrationKind;
@@ -109,12 +152,12 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
return ( return (
<ActiveTabAccordion defaultValue={activeTab} radius="lg" classNames={classes}> <ActiveTabAccordion defaultValue={activeTab} radius="lg" classNames={classes}>
{entries.map(([kind, kindIntegrations], index) => ( {entries.map(([kind, integrations], index) => (
<AccordionItem key={kind} value={kind} data-first={index === 0} data-last={index === entries.length - 1}> <AccordionItem key={kind} value={kind} data-first={index === 0} data-last={index === entries.length - 1}>
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} radius="sm" />}> <AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} radius="sm" />}>
<Group> <Group>
<Text>{getIntegrationName(kind)}</Text> <Text>{getIntegrationName(kind)}</Text>
<CountBadge count={kindIntegrations.length} /> <CountBadge count={integrations.length} />
</Group> </Group>
</AccordionControl> </AccordionControl>
<AccordionPanel> <AccordionPanel>
@@ -127,7 +170,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
{kindIntegrations.map((integration) => ( {integrations.map((integration) => (
<TableTr key={integration.id}> <TableTr key={integration.id}>
<TableTd>{integration.name}</TableTd> <TableTd>{integration.name}</TableTd>
<TableTd> <TableTd>
@@ -148,7 +191,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
> >
<IconPencil size={16} stroke={1.5} /> <IconPencil size={16} stroke={1.5} />
</ActionIcon> </ActionIcon>
<DeleteIntegrationActionButton integration={integration} count={kindIntegrations.length} /> <DeleteIntegrationActionButton integration={integration} count={integrations.length} />
</ActionIconGroup> </ActionIconGroup>
)} )}
</Group> </Group>
@@ -159,9 +202,9 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
</Table> </Table>
<Stack gap="xs" hiddenFrom="md"> <Stack gap="xs" hiddenFrom="md">
{kindIntegrations.map((integration, integrationIndex) => ( {integrations.map((integration, index) => (
<Fragment key={integration.id}> <Fragment key={integration.id}>
{integrationIndex !== 0 && <Divider />} {index !== 0 && <Divider />}
<Stack gap={0}> <Stack gap={0}>
<Group justify="space-between" align="center" wrap="nowrap"> <Group justify="space-between" align="center" wrap="nowrap">
<Text>{integration.name}</Text> <Text>{integration.name}</Text>
@@ -176,7 +219,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
> >
<IconPencil size={16} stroke={1.5} /> <IconPencil size={16} stroke={1.5} />
</ActionIcon> </ActionIcon>
<DeleteIntegrationActionButton integration={integration} count={kindIntegrations.length} /> <DeleteIntegrationActionButton integration={integration} count={integrations.length} />
</ActionIconGroup> </ActionIconGroup>
)} )}
</Group> </Group>
+1 -16
View File
@@ -36,7 +36,6 @@ import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import type { NavigationLink } from "~/components/layout/navigation"; import type { NavigationLink } from "~/components/layout/navigation";
import { MainNavigation } from "~/components/layout/navigation"; import { MainNavigation } from "~/components/layout/navigation";
import { ClientShell } from "~/components/layout/shell"; import { ClientShell } from "~/components/layout/shell";
import { ManageTourProvider } from "~/components/onboarding/manage-tour";
export default async function ManageLayout({ children }: PropsWithChildren) { export default async function ManageLayout({ children }: PropsWithChildren) {
const t = await getScopedI18n("management.navbar"); const t = await getScopedI18n("management.navbar");
@@ -46,13 +45,11 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
label: t("items.home"), label: t("items.home"),
icon: IconHomeFilled, icon: IconHomeFilled,
href: "/manage", href: "/manage",
"data-onboarding-tour-id": "manage-welcome",
}, },
{ {
icon: IconLayoutDashboardFilled, icon: IconLayoutDashboardFilled,
href: "/manage/boards", href: "/manage/boards",
label: t("items.boards"), label: t("items.boards"),
"data-onboarding-tour-id": "manage-boards",
}, },
{ {
icon: IconBox, icon: IconBox,
@@ -62,14 +59,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
iconProps: { iconProps: {
strokeWidth: 2.5, strokeWidth: 2.5,
}, },
"data-onboarding-tour-id": "manage-apps",
}, },
{ {
icon: IconAffiliateFilled, icon: IconAffiliateFilled,
href: "/manage/integrations", href: "/manage/integrations",
label: t("items.integrations"), label: t("items.integrations"),
hidden: !session, hidden: !session,
"data-onboarding-tour-id": "manage-integrations",
}, },
{ {
icon: IconSearch, icon: IconSearch,
@@ -79,20 +74,17 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
iconProps: { iconProps: {
strokeWidth: 2.5, strokeWidth: 2.5,
}, },
"data-onboarding-tour-id": "manage-search-engines",
}, },
{ {
icon: IconPhotoFilled, icon: IconPhotoFilled,
href: "/manage/medias", href: "/manage/medias",
label: t("items.medias"), label: t("items.medias"),
hidden: !session, hidden: !session,
"data-onboarding-tour-id": "manage-medias",
}, },
{ {
icon: IconUserFilled, icon: IconUserFilled,
label: t("items.users.label"), label: t("items.users.label"),
hidden: !session?.user.permissions.includes("admin"), hidden: !session?.user.permissions.includes("admin"),
"data-onboarding-tour-id": "manage-users",
items: [ items: [
{ {
label: t("items.users.items.manage"), label: t("items.users.items.manage"),
@@ -161,7 +153,6 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
href: "/manage/settings", href: "/manage/settings",
icon: IconSettingsFilled, icon: IconSettingsFilled,
hidden: !session?.user.permissions.includes("admin"), hidden: !session?.user.permissions.includes("admin"),
"data-onboarding-tour-id": "manage-settings",
}, },
{ {
label: t("items.help.label"), label: t("items.help.label"),
@@ -200,17 +191,11 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
}, },
]; ];
const isAdmin = session?.user.permissions.includes("admin") ?? false; return (
const shell = (
<ClientShell hasNavigation> <ClientShell hasNavigation>
<MainHeader></MainHeader> <MainHeader></MainHeader>
<MainNavigation links={navigationLinks}></MainNavigation> <MainNavigation links={navigationLinks}></MainNavigation>
<AppShellMain>{children}</AppShellMain> <AppShellMain>{children}</AppShellMain>
</ClientShell> </ClientShell>
); );
if (!session) return shell;
return <ManageTourProvider isAdmin={isAdmin}>{shell}</ManageTourProvider>;
} }
@@ -4,15 +4,17 @@ import {
Anchor, Anchor,
Group, Group,
Image, Image,
Stack,
Table, Table,
TableTbody, TableTbody,
TableTd, TableTd,
TableTh, TableTh,
TableThead, TableThead,
TableTr, TableTr,
Title,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { IconExternalLink, IconPhoto } from "@tabler/icons-react"; import { IconExternalLink } from "@tabler/icons-react";
import { z } from "zod/v4"; import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
@@ -24,9 +26,8 @@ import { createLocalImageUrl } from "@homarr/icons/local";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { Link, SearchInput, TablePagination, UserAvatar } from "@homarr/ui"; import { Link, SearchInput, TablePagination, UserAvatar } from "@homarr/ui";
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action"; import { ManageContainer } from "~/components/manage/manage-container";
import { ManagePageLayout } from "~/components/manage/manage-page-layout"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results";
import { CopyMedia } from "./_actions/copy-media"; import { CopyMedia } from "./_actions/copy-media";
import { DeleteMedia } from "./_actions/delete-media"; import { DeleteMedia } from "./_actions/delete-media";
import { IncludeFromAllUsersSwitch } from "./_actions/show-all"; import { IncludeFromAllUsersSwitch } from "./_actions/show-all";
@@ -47,7 +48,7 @@ interface MediaListPageProps {
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>; searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
} }
export default async function MediaListPage(props: MediaListPageProps) { export default async function GroupsListPage(props: MediaListPageProps) {
const session = await auth(); const session = await auth();
if (!session) { if (!session) {
@@ -57,31 +58,22 @@ export default async function MediaListPage(props: MediaListPageProps) {
const t = await getI18n(); const t = await getI18n();
const searchParams = searchParamsSchema.parse(await props.searchParams); const searchParams = searchParamsSchema.parse(await props.searchParams);
const { items: medias, totalCount } = await api.media.getPaginated(searchParams); const { items: medias, totalCount } = await api.media.getPaginated(searchParams);
const canUpload = session.user.permissions.includes("media-upload");
return ( return (
<ManagePageLayout <ManageContainer>
title={t("media.plural")} <DynamicBreadcrumb />
primaryAction={ <Stack>
canUpload ? ( <Title>{t("media.plural")}</Title>
<ManageMobilePrimaryAction> <Group justify="space-between">
<UploadMediaButton /> <Group>
</ManageMobilePrimaryAction> <SearchInput placeholder={`${t("media.search")}...`} defaultValue={searchParams.search} />
) : undefined {session.user.permissions.includes("media-view-all") && (
} <IncludeFromAllUsersSwitch defaultChecked={searchParams.includeFromAllUsers} />
toolbar={ )}
<Group> </Group>
<SearchInput placeholder={`${t("media.search")}...`} defaultValue={searchParams.search} />
{session.user.permissions.includes("media-view-all") && ( {session.user.permissions.includes("media-upload") && <UploadMediaButton />}
<IncludeFromAllUsersSwitch defaultChecked={searchParams.includeFromAllUsers} />
)}
</Group> </Group>
}
footer={<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />}
floatingPrimaryAction={canUpload}
>
{medias.length === 0 && <NoResults icon={IconPhoto} title={t("media.noResults.title")} />}
{medias.length > 0 && (
<Table striped highlightOnHover> <Table striped highlightOnHover>
<TableThead> <TableThead>
<TableTr> <TableTr>
@@ -98,8 +90,13 @@ export default async function MediaListPage(props: MediaListPageProps) {
))} ))}
</TableTbody> </TableTbody>
</Table> </Table>
)}
</ManagePageLayout> {/* Added margin to not hide pagination behind affix-button */}
<Group justify="end" mb={48}>
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
</Group>
</Stack>
</ManageContainer>
); );
} }
@@ -7,7 +7,9 @@ export default async function NotFound() {
return ( return (
<Center h="100%"> <Center h="100%">
<Stack align="center"> <Stack align="center">
<Title order={1}>{t("title")}</Title> <Title order={1} tt="uppercase">
{t("title")}
</Title>
<Text>{t("text")}</Text> <Text>{t("text")}</Text>
</Stack> </Stack>
</Center> </Center>
+5 -5
View File
@@ -29,20 +29,20 @@ export default async function ManagementPage() {
<Space h="md" /> <Space h="md" />
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}> <SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
{statistics.map((statistic) => ( {statistics.map((statistic) => (
<Card component={Link} href={statistic.path} key={statistic.path}> <Card component={Link} href={statistic.path} key={statistic.path} radius="lg">
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Group wrap="nowrap"> <Group wrap="nowrap">
<Text size="xl" fw={700}> <Text size="2.4rem" fw="bolder">
{statistic.count} {statistic.count}
</Text> </Text>
<Stack gap={0}> <Stack gap={0}>
<Text c="dimmed" size="xs"> <Text c="red" size="xs">
{t(`statisticLabel.${statistic.subtitleKey}`)} {t(`statisticLabel.${statistic.subtitleKey}`)}
</Text> </Text>
<Text fw={600}>{t(`statistic.${statistic.titleKey}`)}</Text> <Text fw="bold">{t(`statistic.${statistic.titleKey}`)}</Text>
</Stack> </Stack>
</Group> </Group>
<IconArrowRight size={16} stroke={1.5} color="var(--mantine-color-dimmed)" /> <IconArrowRight />
</Group> </Group>
</Card> </Card>
))} ))}
@@ -1,5 +1,5 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text } from "@mantine/core"; import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
import { IconPencil, IconSearch } from "@tabler/icons-react"; import { IconPencil, IconSearch } from "@tabler/icons-react";
import { z } from "zod/v4"; import { z } from "zod/v4";
@@ -10,8 +10,9 @@ import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { getI18n, getScopedI18n } from "@homarr/translation/server"; import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { Link, SearchInput, TablePagination } from "@homarr/ui"; import { Link, SearchInput, TablePagination } from "@homarr/ui";
import { ManagePageLayout } from "~/components/manage/manage-page-layout"; import { ManageContainer } from "~/components/manage/manage-container";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results"; import { NoResults } from "~/components/no-results";
import { SearchEngineDeleteButton } from "./_search-engine-delete-button"; import { SearchEngineDeleteButton } from "./_search-engine-delete-button";
@@ -37,31 +38,34 @@ export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
const tEngine = await getScopedI18n("search.engine"); const tEngine = await getScopedI18n("search.engine");
const canCreate = session.user.permissions.includes("search-engine-create");
return ( return (
<ManagePageLayout <ManageContainer>
title={tEngine("page.list.title")} <DynamicBreadcrumb />
primaryAction={ <Stack>
canCreate ? ( <Title>{tEngine("page.list.title")}</Title>
<MobileAffixButton component={Link} href="/manage/search-engines/new"> <Group justify="space-between" align="center">
{tEngine("page.create.title")} <SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} flexExpand />
</MobileAffixButton> {session.user.permissions.includes("search-engine-create") && (
) : undefined <MobileAffixButton component={Link} href="/manage/search-engines/new">
} {tEngine("page.create.title")}
toolbar={<SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} flexExpand />} </MobileAffixButton>
footer={<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />} )}
floatingPrimaryAction={canCreate} </Group>
> {searchEngines.length === 0 && <SearchEngineNoResults />}
{searchEngines.length === 0 && <SearchEngineNoResults />} {searchEngines.length > 0 && (
{searchEngines.length > 0 && ( <Stack gap="sm">
<Stack gap="sm"> {searchEngines.map((searchEngine) => (
{searchEngines.map((searchEngine) => ( <SearchEngineCard key={searchEngine.id} searchEngine={searchEngine} />
<SearchEngineCard key={searchEngine.id} searchEngine={searchEngine} /> ))}
))} </Stack>
</Stack> )}
)}
</ManagePageLayout> {/* Added margin to not hide pagination behind affix-button */}
<Group justify="end" mb={48}>
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
</Group>
</Stack>
</ManageContainer>
); );
} }
@@ -74,7 +78,7 @@ const SearchEngineCard = async ({ searchEngine }: SearchEngineCardProps) => {
const session = await auth(); const session = await auth();
return ( return (
<Card> <Card withBorder>
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Group align="top" justify="start" wrap="nowrap" style={{ flex: 1 }}> <Group align="top" justify="start" wrap="nowrap" style={{ flex: 1 }}>
<Avatar <Avatar
@@ -41,8 +41,8 @@ export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
<> <>
<Title order={2}>{t("title")}</Title> <Title order={2}>{t("title")}</Title>
<Card pos="relative"> <Card pos="relative" withBorder>
<LoadingOverlay visible={isPending} /> <LoadingOverlay visible={isPending} zIndex={1000} overlayProps={{ radius: "sm", blur: 2 }} />
<Stack> <Stack>
<SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} /> <SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} />
</Stack> </Stack>
@@ -41,11 +41,11 @@ export const CrawlingAndIndexingSettings = ({ initialData }: CrawlingAndIndexing
<> <>
<Title order={2}>{t("title")}</Title> <Title order={2}>{t("title")}</Title>
<Card pos="relative"> <Card pos="relative" withBorder>
<Text c={"dimmed"} mb={"lg"}> <Text c={"dimmed"} mb={"lg"}>
{t("warning")} {t("warning")}
</Text> </Text>
<LoadingOverlay visible={isPending} /> <LoadingOverlay visible={isPending} zIndex={1000} overlayProps={{ radius: "sm", blur: 2 }} />
<Stack> <Stack>
<SwitchSetting form={form} formKey="noIndex" title={t("noIndex.title")} text={t("noIndex.text")} /> <SwitchSetting form={form} formKey="noIndex" title={t("noIndex.title")} text={t("noIndex.text")} />
<SwitchSetting form={form} formKey="noFollow" title={t("noFollow.title")} text={t("noFollow.text")} /> <SwitchSetting form={form} formKey="noFollow" title={t("noFollow.title")} text={t("noFollow.text")} />
@@ -15,7 +15,7 @@ export const SwitchSetting = <TFormValue extends Record<string, boolean>>({
disabled, disabled,
}: { }: {
form: Modify< form: Modify<
UseFormReturnType<TFormValue, TFormValue>, UseFormReturnType<TFormValue, () => TFormValue>,
{ {
setFieldValue: (key: keyof TFormValue, value: (previous: boolean) => boolean) => void; setFieldValue: (key: keyof TFormValue, value: (previous: boolean) => boolean) => void;
} }
@@ -79,7 +79,7 @@ export default async function CertificatesPage({ params }: CertificatesPageProps
<SimpleGrid cols={{ sm: 1, lg: 2, xl: 3 }} spacing="lg"> <SimpleGrid cols={{ sm: 1, lg: 2, xl: 3 }} spacing="lg">
{x509Certificates.map((cert) => ( {x509Certificates.map((cert) => (
<Card key={cert.fileName}> <Card key={cert.fileName} withBorder>
<Group wrap="nowrap"> <Group wrap="nowrap">
{cert.isError ? ( {cert.isError ? (
<IconAlertTriangle <IconAlertTriangle
@@ -1,26 +1,23 @@
"use client"; "use client";
import { useRouter } from "next/navigation";
import type { MantineColor } from "@mantine/core"; import type { MantineColor } from "@mantine/core";
import { ActionIcon, Avatar, Badge, Box, Button, Group, Menu, Text, Tooltip } from "@mantine/core"; import { Avatar, Badge, Box, Button, Group, Text } from "@mantine/core";
import { import {
IconCategoryPlus, IconCategoryPlus,
IconDots,
IconFileText,
IconPlayerPlay, IconPlayerPlay,
IconPlayerStop, IconPlayerStop,
IconRefresh, IconRefresh,
IconRotateClockwise, IconRotateClockwise,
IconTrash, IconTrash,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import type { MRT_ColumnDef, MRT_Row } from "mantine-react-table"; import type { MRT_ColumnDef } from "mantine-react-table";
import { MantineReactTable } from "mantine-react-table"; import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { humanFileSize, useTimeAgo } from "@homarr/common"; import { useTimeAgo } from "@homarr/common";
import type { ContainerState } from "@homarr/docker"; import type { ContainerState } from "@homarr/docker";
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared"; import { containerStateColorMap } from "@homarr/docker/shared";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
import { AddDockerAppToHomarr } from "@homarr/modals-collection"; import { AddDockerAppToHomarr } from "@homarr/modals-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -30,30 +27,25 @@ import type { TablerIcon } from "@homarr/ui";
import { OverflowBadge } from "@homarr/ui"; import { OverflowBadge } from "@homarr/ui";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
type DockerContainer = RouterOutputs["docker"]["getContainers"]["containers"][number]; const createColumns = (
t: TranslationFunction,
const createContainerLogsPath = (container: Pick<DockerContainer, "id" | "name">) => ): MRT_ColumnDef<RouterOutputs["docker"]["getContainers"]["containers"][number]>[] => [
`/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}`;
const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[] => [
{ {
accessorKey: "name", accessorKey: "name",
header: t("docker.field.name.label"), header: t("docker.field.name.label"),
Cell({ renderedCellValue, row }) { Cell({ renderedCellValue, row }) {
return ( return (
<Group gap="xs" wrap="nowrap"> <Group gap="xs">
<Avatar <Avatar
variant="outline" variant="outline"
radius="sm" radius="lg"
size="md" size="md"
styles={{ image: { objectFit: "contain" } }} styles={{ image: { objectFit: "contain" } }}
src={row.original.iconUrl} src={row.original.iconUrl}
> >
{row.original.name.at(0)?.toUpperCase()} {row.original.name.at(0)?.toUpperCase()}
</Avatar> </Avatar>
<Text truncate="end" maw={500}> <Text>{renderedCellValue}</Text>
{renderedCellValue}
</Text>
</Group> </Group>
); );
}, },
@@ -84,11 +76,11 @@ const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[]
maxSize: 200, maxSize: 200,
Cell({ renderedCellValue, cell }) { Cell({ renderedCellValue, cell }) {
return ( return (
<Tooltip label={cell.row.original.image} multiline maw={400}> <Box maw={200}>
<Box maw={200}> <Text truncate="end" title={cell.row.original.image}>
<Text truncate="end">{renderedCellValue}</Text> {renderedCellValue}
</Box> </Text>
</Tooltip> </Box>
); );
}, },
}, },
@@ -103,79 +95,18 @@ const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[]
); );
}, },
}, },
{
id: "cpuUsage",
accessorKey: "cpuUsage",
size: 120,
header: t("docker.field.stats.cpu.label"),
enableHiding: true,
sortingFn: (rowA, rowB) => {
const cpuUsageA = safeValue(rowA.original.cpuUsage);
const cpuUsageB = safeValue(rowB.original.cpuUsage);
return cpuUsageA - cpuUsageB;
},
Cell({ row }) {
const cpuUsage = safeValue(row.original.cpuUsage);
return (
<Text size="sm" c={cpuUsageColor(cpuUsage, row.original.state)}>
{cpuUsage.toFixed(2)}%
</Text>
);
},
},
{
id: "memoryUsage",
accessorKey: "memoryUsage",
size: 140,
header: t("docker.field.stats.memory.label"),
enableHiding: true,
sortingFn: (rowA, rowB) => {
const memoryUsageA = safeValue(rowA.original.memoryUsage);
const memoryUsageB = safeValue(rowB.original.memoryUsage);
return memoryUsageA - memoryUsageB;
},
Cell({ row }) {
const bytesUsage = safeValue(row.original.memoryUsage);
return (
<Text size="sm" c={memoryUsageColor(bytesUsage, row.original.state)}>
{humanFileSize(bytesUsage)}
</Text>
);
},
},
]; ];
export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["getContainers"]) { export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"]) {
const t = useI18n(); const t = useI18n();
const tDocker = useScopedI18n("docker"); const tDocker = useScopedI18n("docker");
const { data } = clientApi.docker.getContainers.useQuery(undefined, { const { data } = clientApi.docker.getContainers.useQuery(undefined, {
initialData: { containers, timestamp }, initialData,
refetchOnMount: false, refetchOnMount: false,
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
refetchOnReconnect: false, refetchOnReconnect: false,
}); });
const relativeTime = useTimeAgo(data.timestamp); const relativeTime = useTimeAgo(data.timestamp);
const utils = clientApi.useUtils();
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
async onSuccess() {
await utils.docker.getContainers.invalidate();
showSuccessNotification({
title: tDocker("action.refresh.notification.success.title"),
message: tDocker("action.refresh.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: tDocker("action.refresh.notification.error.title"),
message: tDocker("action.refresh.notification.error.message"),
});
},
});
const table = useTranslatedMantineReactTable({ const table = useTranslatedMantineReactTable({
data: data.containers, data: data.containers,
enableDensityToggle: false, enableDensityToggle: false,
@@ -183,8 +114,6 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["
enableColumnFilters: false, enableColumnFilters: false,
enablePagination: false, enablePagination: false,
enableRowSelection: true, enableRowSelection: true,
enableRowActions: true,
positionActionsColumn: "last",
positionToolbarAlertBanner: "top", positionToolbarAlertBanner: "top",
enableTableFooter: false, enableTableFooter: false,
enableBottomToolbar: false, enableBottomToolbar: false,
@@ -195,15 +124,36 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["
autoFocus: true, autoFocus: true,
}, },
initialState: { density: "xs", showGlobalFilter: true, columnVisibility: { cpuUsage: false, memoryUsage: false } }, initialState: { density: "xs", showGlobalFilter: true },
mantineTableBodyCellProps: { style: { padding: "4px 8px" } }, renderTopToolbarCustomActions: () => {
mantineTableHeadCellProps: { style: { padding: "4px 8px" } }, const utils = clientApi.useUtils();
renderRowActions: ({ row }: { row: MRT_Row<DockerContainer> }) => <ContainerRowMenu container={row.original} />, const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
renderTopToolbarCustomActions: () => ( async onSuccess() {
<Button variant="default" rightSection={<IconRefresh size="1rem" />} onClick={() => mutate()} loading={isPending}> await utils.docker.getContainers.invalidate();
{tDocker("action.refresh.label")} showSuccessNotification({
</Button> title: tDocker("action.refresh.notification.success.title"),
), message: tDocker("action.refresh.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: tDocker("action.refresh.notification.error.title"),
message: tDocker("action.refresh.notification.error.message"),
});
},
});
return (
<Button
variant="default"
rightSection={<IconRefresh size="1rem" />}
onClick={() => mutate()}
loading={isPending}
>
{tDocker("action.refresh.label")}
</Button>
);
},
renderToolbarAlertBannerContent: ({ groupedAlert, table }) => { renderToolbarAlertBannerContent: ({ groupedAlert, table }) => {
const dockerContainers = table.getSelectedRowModel().rows.map((row) => row.original); const dockerContainers = table.getSelectedRowModel().rows.map((row) => row.original);
return ( return (
@@ -231,104 +181,28 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["
); );
} }
const containerActions: { action: "start" | "stop" | "restart" | "remove"; icon: TablerIcon; color: MantineColor }[] = [
{ action: "start", icon: IconPlayerPlay, color: "green" },
{ action: "stop", icon: IconPlayerStop, color: "red" },
{ action: "restart", icon: IconRotateClockwise, color: "orange" },
{ action: "remove", icon: IconTrash, color: "red" },
];
const ContainerRowMenu = ({ container }: { container: DockerContainer }) => {
const t = useScopedI18n("docker.action");
const router = useRouter();
const utils = clientApi.useUtils();
const { openModal } = useModalAction(AddDockerAppToHomarr);
const useContainerAction = (action: "start" | "stop" | "restart" | "remove") =>
clientApi.docker[`${action}All`].useMutation({
async onSettled() {
await utils.docker.getContainers.invalidate();
},
onSuccess() {
showSuccessNotification({
title: t(`${action}.notification.success.title`),
message: t(`${action}.notification.success.message`),
});
},
onError() {
showErrorNotification({
title: t(`${action}.notification.error.title`),
message: t(`${action}.notification.error.message`),
});
},
});
const mutations = {
start: useContainerAction("start"),
stop: useContainerAction("stop"),
restart: useContainerAction("restart"),
remove: useContainerAction("remove"),
};
return (
<Menu withinPortal position="bottom-end" shadow="sm">
<Menu.Target>
<ActionIcon variant="subtle" color="gray" onClick={(e) => e.stopPropagation()}>
<IconDots size="1rem" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconFileText size="1rem" />}
onClick={() => router.push(createContainerLogsPath(container))}
>
{t("logs.label")}
</Menu.Item>
<Menu.Divider />
{containerActions.map(({ action, icon: Icon, color }) => (
<Menu.Item
key={action}
leftSection={<Icon size="1rem" />}
color={color}
onClick={() => mutations[action].mutate({ ids: [container.id] })}
>
{t(`${action}.label`)}
</Menu.Item>
))}
<Menu.Divider />
<Menu.Item
leftSection={<IconCategoryPlus size="1rem" />}
color="red"
onClick={() => openModal({ selectedContainers: [container] })}
>
{t("addToHomarr.label")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};
interface ContainerActionBarProps { interface ContainerActionBarProps {
selectedContainers: DockerContainer[]; selectedContainers: RouterOutputs["docker"]["getContainers"]["containers"];
} }
const ContainerActionBar = ({ selectedContainers }: ContainerActionBarProps) => { const ContainerActionBar = ({ selectedContainers }: ContainerActionBarProps) => {
const t = useScopedI18n("docker.action"); const t = useScopedI18n("docker.action");
const { openModal } = useModalAction(AddDockerAppToHomarr); const { openModal } = useModalAction(AddDockerAppToHomarr);
const handleClick = () => {
openModal({
selectedContainers,
});
};
const selectedIds = selectedContainers.map((container) => container.id); const selectedIds = selectedContainers.map((container) => container.id);
return ( return (
<Group gap="xs"> <Group gap="xs">
{containerActions.map(({ action, icon, color }) => ( <ContainerActionBarButton icon={IconPlayerPlay} color="green" action="start" selectedIds={selectedIds} />
<ContainerActionBarButton key={action} icon={icon} color={color} action={action} selectedIds={selectedIds} /> <ContainerActionBarButton icon={IconPlayerStop} color="red" action="stop" selectedIds={selectedIds} />
))} <ContainerActionBarButton icon={IconRotateClockwise} color="orange" action="restart" selectedIds={selectedIds} />
<Button <ContainerActionBarButton icon={IconTrash} color="red" action="remove" selectedIds={selectedIds} />
leftSection={<IconCategoryPlus />} <Button leftSection={<IconCategoryPlus />} color={"red"} onClick={handleClick} variant="light" radius="md">
color="red"
onClick={() => openModal({ selectedContainers })}
variant="light"
>
{t("addToHomarr.label")} {t("addToHomarr.label")}
</Button> </Button>
</Group> </Group>
@@ -379,6 +253,7 @@ const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
onClick={handleClickAsync} onClick={handleClickAsync}
loading={isPending} loading={isPending}
variant="light" variant="light"
radius="md"
> >
{t(`${props.action}.label`)} {t(`${props.action}.label`)}
</Button> </Button>
@@ -1,10 +0,0 @@
"use client";
import dynamic from "next/dynamic";
export const ClientSideDockerLogsTerminal = dynamic(
() => import("./terminal").then(({ DockerLogsTerminal }) => DockerLogsTerminal),
{
ssr: false,
},
);
@@ -1,47 +0,0 @@
import { notFound } from "next/navigation";
import { Box, Group } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { env } from "@homarr/docker/env";
import "@xterm/xterm/css/xterm.css";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
import { ClientSideDockerLogsTerminal } from "./client";
interface DockerContainerLogsPageProps {
params: Promise<{ containerId: string }>;
searchParams: Promise<{ name?: string }>;
}
export default async function DockerContainerLogsPage({ params, searchParams }: DockerContainerLogsPageProps) {
const session = await auth();
if (!(session?.user.permissions.includes("admin") && env.ENABLE_DOCKER)) {
notFound();
}
const { containerId } = await params;
const { name } = await searchParams;
try {
await api.docker.logs({ id: containerId, tail: 1 });
} catch {
notFound();
}
return (
<>
<Group justify="space-between" align="center" wrap="nowrap">
<DynamicBreadcrumb
dynamicMappings={new Map([[containerId, name ?? containerId]])}
nonInteractable={[containerId]}
/>
</Group>
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
<ClientSideDockerLogsTerminal containerId={containerId} />
</Box>
</>
);
}
@@ -1,3 +0,0 @@
.outerTerminal > div {
height: 100%;
}
@@ -1,81 +0,0 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { Box } from "@mantine/core";
import { CanvasAddon } from "@xterm/addon-canvas";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import { clientApi } from "@homarr/api/client";
import classes from "./terminal.module.css";
interface DockerLogsTerminalProps {
containerId: string;
}
export const DockerLogsTerminal = ({ containerId }: DockerLogsTerminalProps) => {
const ref = useRef<HTMLDivElement>(null);
const terminalRef = useRef<Terminal | null>(null);
const pendingLogsRef = useRef<string[]>([]);
const isTerminalReadyRef = useRef(false);
const writeToTerminal = useCallback((data: string) => {
if (!isTerminalReadyRef.current || !terminalRef.current) {
pendingLogsRef.current.push(data);
return;
}
terminalRef.current.write(data);
terminalRef.current.refresh(0, terminalRef.current.rows - 1);
}, []);
clientApi.docker.subscribeLogs.useSubscription(
{ id: containerId, tail: 200 },
{
onData: writeToTerminal,
onError(err) {
writeToTerminal(`\r\n\x1b[31m${err.message}\x1b[0m`);
},
},
);
useEffect(() => {
if (!ref.current) {
return () => undefined;
}
const canvasAddon = new CanvasAddon();
terminalRef.current = new Terminal({
cursorBlink: false,
disableStdin: true,
convertEol: true,
fontSize: 14,
});
terminalRef.current.open(ref.current);
terminalRef.current.loadAddon(canvasAddon);
isTerminalReadyRef.current = true;
const terminal = terminalRef.current;
pendingLogsRef.current.forEach((data) => terminal.write(data));
pendingLogsRef.current = [];
terminal.refresh(0, terminal.rows - 1);
setTimeout(() => {
const fitAddon = new FitAddon();
terminalRef.current?.loadAddon(fitAddon);
fitAddon.fit();
});
return () => {
isTerminalReadyRef.current = false;
pendingLogsRef.current = [];
canvasAddon.dispose();
terminalRef.current?.dispose();
terminalRef.current = null;
};
}, [writeToTerminal]);
return <Box ref={ref} className={classes.outerTerminal} h="100%" />;
};
@@ -4,16 +4,28 @@
overflow: hidden; overflow: hidden;
padding: var(--mantine-spacing-xs); padding: var(--mantine-spacing-xs);
padding-left: calc(var(--mantine-spacing-xs) * 2); padding-left: calc(var(--mantine-spacing-xs) * 2);
border-left: 1px solid var(--mantine-color-blue-6);
background-color: light-dark( @mixin hover {
var(--mantine-color-blue-0), box-shadow: var(--mantine-shadow-md);
color-mix(in oklch, var(--mantine-color-blue-9), transparent 85%) }
);
&::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 6px;
background-image: linear-gradient(0, var(--mantine-color-blue-4), var(--mantine-color-blue-9));
}
@mixin rtl { @mixin rtl {
padding-left: var(--mantine-spacing-xs); padding-left: var(--mantine-spacing-xs);
padding-right: calc(var(--mantine-spacing-xs) * 2); padding-right: calc(var(--mantine-spacing-xs) * 2);
border-left: none;
border-right: 1px solid var(--mantine-color-blue-6); &::before {
left: unset;
right: 0;
}
} }
} }
@@ -23,6 +23,7 @@ export function HeaderCard(props: HeaderCardProps) {
<Flex align="center" justify={isRTL ? "space-between" : "flex-start"} gap="md" direction={"row"}> <Flex align="center" justify={isRTL ? "space-between" : "flex-start"} gap="md" direction={"row"}>
<ThemeIcon <ThemeIcon
size="xl" size="xl"
radius="md"
variant="gradient" variant="gradient"
gradient={{ gradient={{
deg: 0, deg: 0,
@@ -8,8 +8,6 @@ import { useCurrentLocale, useI18n } from "@homarr/translation/client";
import { ResourceIcon } from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-gauge/resource-icon"; import { ResourceIcon } from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-gauge/resource-icon";
import classes from "./resource-gauge.module.css"; import classes from "./resource-gauge.module.css";
const KUBERNETES_BRAND_COLOR = "#326ce5";
interface KubernetesResourceGaugeProps { interface KubernetesResourceGaugeProps {
kubernetesCapacity: KubernetesCapacity; kubernetesCapacity: KubernetesCapacity;
} }
@@ -20,8 +18,8 @@ export function ResourceGauge(props: KubernetesResourceGaugeProps) {
const isRTL = Boolean(isLocaleRTL(currentLocale)); const isRTL = Boolean(isLocaleRTL(currentLocale));
return ( return (
<Paper className={classes.paper} mt={20}> <Paper radius="md" withBorder className={classes.paper} mt={20}>
<ThemeIcon className={classes.icon} size={60} radius={60} bg={KUBERNETES_BRAND_COLOR}> <ThemeIcon className={classes.icon} size={60} radius={60} bg={"#326ce5"}>
<ResourceIcon type={props.kubernetesCapacity.type} /> <ResourceIcon type={props.kubernetesCapacity.type} />
</ThemeIcon> </ThemeIcon>
@@ -17,7 +17,12 @@ interface ResourceTileProps {
export function ResourceTile(props: ResourceTileProps) { export function ResourceTile(props: ResourceTileProps) {
const t = useI18n(); const t = useI18n();
return ( return (
<Card component={Link} href={`/manage/tools/kubernetes/${props.label}`} className={classes.cardContainer}> <Card
withBorder
component={Link}
href={`/manage/tools/kubernetes/${props.label}`}
className={classes.cardContainer}
>
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Image src={`/images/kubernetes/${props.label}.svg`} alt={props.label} width={64} height={64} /> <Image src={`/images/kubernetes/${props.label}.svg`} alt={props.label} width={64} height={64} />
<Group gap="xs"> <Group gap="xs">
@@ -1,29 +0,0 @@
"use client";
import { Group, Slider, Text } from "@mantine/core";
import { IconTextSize } from "@tabler/icons-react";
import { useLogContext } from "./log-context";
export const LogFontSizeSlider = () => {
const { fontSize, setFontSize } = useLogContext();
return (
<Group gap="xs" wrap="nowrap" w={160}>
<IconTextSize size={18} style={{ flexShrink: 0 }} />
<Slider
min={10}
max={24}
step={1}
value={fontSize}
onChange={setFontSize}
label={(val) => `${val}px`}
style={{ flex: 1 }}
size="xs"
/>
<Text size="xs" c="dimmed" w={32} ta="right">
{fontSize}px
</Text>
</Group>
);
};
@@ -2,14 +2,12 @@
import { Select } from "@mantine/core"; import { Select } from "@mantine/core";
import { logLevelConfiguration, logLevels } from "@homarr/core/infrastructure/logs/constants";
import type { LogLevel } from "@homarr/core/infrastructure/logs/constants"; import type { LogLevel } from "@homarr/core/infrastructure/logs/constants";
import { logLevelConfiguration, logLevels } from "@homarr/core/infrastructure/logs/constants";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { useLogContext } from "./log-context"; import { useLogContext } from "./log-context";
const levelByValue = Object.fromEntries(logLevels.map((logLevel) => [logLevel, logLevel])) as Record<string, LogLevel>;
export const LogLevelSelection = () => { export const LogLevelSelection = () => {
const { level, setLevel } = useLogContext(); const { level, setLevel } = useLogContext();
const t = useI18n(); const t = useI18n();
@@ -21,12 +19,7 @@ export const LogLevelSelection = () => {
label: `${logLevelConfiguration[level].prefix} ${t(`log.level.option.${level}`)}`, label: `${logLevelConfiguration[level].prefix} ${t(`log.level.option.${level}`)}`,
}))} }))}
value={level} value={level}
onChange={(value) => { onChange={(value) => setLevel(value as LogLevel)}
const nextLevel = value && levelByValue[value];
if (nextLevel) {
setLevel(nextLevel);
}
}}
checkIconPosition="right" checkIconPosition="right"
/> />
); );
@@ -6,23 +6,10 @@ import { createContext, useContext, useMemo, useState } from "react";
import type { LogLevel } from "@homarr/core/infrastructure/logs/constants"; import type { LogLevel } from "@homarr/core/infrastructure/logs/constants";
import { logLevels } from "@homarr/core/infrastructure/logs/constants"; import { logLevels } from "@homarr/core/infrastructure/logs/constants";
const FONT_SIZE_KEY = "homarr-log-font-size";
const DEFAULT_FONT_SIZE = 14;
const readStoredFontSize = () => {
if (typeof window === "undefined") return DEFAULT_FONT_SIZE;
const stored = localStorage.getItem(FONT_SIZE_KEY);
if (!stored) return DEFAULT_FONT_SIZE;
const parsed = Number(stored);
return Number.isFinite(parsed) ? Math.min(Math.max(parsed, 10), 24) : DEFAULT_FONT_SIZE;
};
const LogContext = createContext<{ const LogContext = createContext<{
level: LogLevel; level: LogLevel;
setLevel: (level: LogLevel) => void; setLevel: (level: LogLevel) => void;
activeLevels: LogLevel[]; activeLevels: LogLevel[];
fontSize: number;
setFontSize: (size: number) => void;
} | null>(null); } | null>(null);
interface LogContextProviderProps extends PropsWithChildren { interface LogContextProviderProps extends PropsWithChildren {
@@ -31,19 +18,9 @@ interface LogContextProviderProps extends PropsWithChildren {
export const LogContextProvider = ({ defaultLevel, children }: LogContextProviderProps) => { export const LogContextProvider = ({ defaultLevel, children }: LogContextProviderProps) => {
const [level, setLevel] = useState(defaultLevel); const [level, setLevel] = useState(defaultLevel);
const [fontSize, setFontSizeRaw] = useState(readStoredFontSize);
const activeLevels = useMemo(() => logLevels.slice(0, logLevels.indexOf(level) + 1), [level]); const activeLevels = useMemo(() => logLevels.slice(0, logLevels.indexOf(level) + 1), [level]);
const setFontSize = (size: number) => { return <LogContext.Provider value={{ level, setLevel, activeLevels }}>{children}</LogContext.Provider>;
setFontSizeRaw(size);
localStorage.setItem(FONT_SIZE_KEY, String(size));
};
return (
<LogContext.Provider value={{ level, setLevel, activeLevels, fontSize, setFontSize }}>
{children}
</LogContext.Provider>
);
}; };
export const useLogContext = () => { export const useLogContext = () => {
@@ -13,7 +13,6 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { fullHeightWithoutHeaderAndFooter } from "~/constants"; import { fullHeightWithoutHeaderAndFooter } from "~/constants";
import { createMetaTitle } from "~/metadata"; import { createMetaTitle } from "~/metadata";
import { ClientSideTerminalComponent } from "./client"; import { ClientSideTerminalComponent } from "./client";
import { LogFontSizeSlider } from "./font-size-slider";
import { LogLevelSelection } from "./level-selection"; import { LogLevelSelection } from "./level-selection";
import { LogContextProvider } from "./log-context"; import { LogContextProvider } from "./log-context";
@@ -39,10 +38,7 @@ export default async function LogsManagementPage() {
<LogContextProvider defaultLevel={logsEnv.LEVEL}> <LogContextProvider defaultLevel={logsEnv.LEVEL}>
<Group justify="space-between" align="center" wrap="nowrap"> <Group justify="space-between" align="center" wrap="nowrap">
<DynamicBreadcrumb /> <DynamicBreadcrumb />
<Group gap="md" wrap="nowrap"> <LogLevelSelection />
<LogFontSizeSlider />
<LogLevelSelection />
</Group>
</Group> </Group>
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black"> <Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
<ClientSideTerminalComponent /> <ClientSideTerminalComponent />
@@ -13,11 +13,9 @@ import classes from "./terminal.module.css";
export const TerminalComponent = () => { export const TerminalComponent = () => {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const { activeLevels, fontSize } = useLogContext(); const { activeLevels } = useLogContext();
const terminalRef = useRef<Terminal>(null); const terminalRef = useRef<Terminal>(null);
const fitAddonRef = useRef<FitAddon>(null);
clientApi.log.subscribe.useSubscription( clientApi.log.subscribe.useSubscription(
{ {
levels: activeLevels, levels: activeLevels,
@@ -28,6 +26,7 @@ export const TerminalComponent = () => {
terminalRef.current?.refresh(0, terminalRef.current.rows - 1); terminalRef.current?.refresh(0, terminalRef.current.rows - 1);
}, },
onError(err) { onError(err) {
// This makes sense as logging might cause an infinite loop
alert(err); alert(err);
}, },
}, },
@@ -44,30 +43,22 @@ export const TerminalComponent = () => {
cursorBlink: false, cursorBlink: false,
disableStdin: true, disableStdin: true,
convertEol: true, convertEol: true,
fontSize,
}); });
terminalRef.current.open(ref.current); terminalRef.current.open(ref.current);
terminalRef.current.loadAddon(canvasAddon); terminalRef.current.loadAddon(canvasAddon);
// This is a hack to make sure the terminal is rendered before we try to fit it
// You can blame @Meierschlumpf for this
setTimeout(() => { setTimeout(() => {
const fitAddon = new FitAddon(); const fitAddon = new FitAddon();
fitAddonRef.current = fitAddon;
terminalRef.current?.loadAddon(fitAddon); terminalRef.current?.loadAddon(fitAddon);
fitAddon.fit(); fitAddon.fit();
}); });
return () => { return () => {
canvasAddon.dispose();
terminalRef.current?.dispose(); terminalRef.current?.dispose();
terminalRef.current = null; canvasAddon.dispose();
}; };
}, []); }, []);
useEffect(() => {
if (!terminalRef.current) return;
terminalRef.current.options.fontSize = fontSize;
fitAddonRef.current?.fit();
}, [fontSize]);
return <Box ref={ref} id="terminal" className={classes.outerTerminal} h="100%"></Box>; return <Box ref={ref} id="terminal" className={classes.outerTerminal} h="100%"></Box>;
}; };
@@ -62,7 +62,7 @@ export const FirstDayOfWeek = ({ user }: FirstDayOfWeekProps) => {
return ( return (
<form onSubmit={form.onSubmit(handleSubmit)}> <form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md"> <Stack gap="md">
<Radio.Group {...inputProps} value={value} onChange={(nextValue: string) => onChange(parseInt(nextValue, 10))}> <Radio.Group {...inputProps} value={value} onChange={(value) => onChange(parseInt(value))}>
<Group mt="xs"> <Group mt="xs">
<Radio value="1" label={weekDays[1]} /> <Radio value="1" label={weekDays[1]} />
<Radio value="6" label={weekDays[6]} /> <Radio value="6" label={weekDays[6]} />
@@ -1,332 +0,0 @@
"use client";
import { useEffect, useMemo, useRef } from "react";
import {
Button,
Card,
Divider,
Group,
Radio,
Select,
SimpleGrid,
Stack,
Switch,
Text,
TextInput,
Title,
} from "@mantine/core";
import type { DayOfWeek } from "@mantine/dates";
import dayjs from "dayjs";
import localeData from "dayjs/plugin/localeData";
import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import {
userChangeHomeBoardsSchema,
userChangeSearchPreferencesSchema,
userEditProfileSchema,
userFirstDayOfWeekSchema,
userPingIconsEnabledSchema,
} from "@homarr/validation/user";
import type { Board } from "~/app/[locale]/boards/_types";
import { BoardSelect } from "~/components/board/board-select";
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
dayjs.extend(localeData);
const userGeneralSettingsSchema = z.object({
name: userEditProfileSchema.shape.name,
email: userEditProfileSchema.shape.email,
homeBoardId: userChangeHomeBoardsSchema.shape.homeBoardId,
mobileHomeBoardId: userChangeHomeBoardsSchema.shape.mobileHomeBoardId,
defaultSearchEngineId: userChangeSearchPreferencesSchema.shape.defaultSearchEngineId,
openInNewTab: userChangeSearchPreferencesSchema.shape.openInNewTab,
ddgBangsEnabled: userChangeSearchPreferencesSchema.shape.ddgBangsEnabled,
firstDayOfWeek: userFirstDayOfWeekSchema.shape.firstDayOfWeek,
pingIconsEnabled: userPingIconsEnabledSchema.shape.pingIconsEnabled,
});
type FormValues = z.infer<typeof userGeneralSettingsSchema>;
const FIRST_DAY_OPTIONS: { value: DayOfWeek; labelKey: number }[] = [
{ value: 1, labelKey: 1 },
{ value: 6, labelKey: 6 },
{ value: 0, labelKey: 0 },
];
interface UserGeneralSettingsFormProps {
user: RouterOutputs["user"]["getById"];
boardsData: Pick<Board, "id" | "name" | "logoImageUrl">[];
searchEnginesData: { value: string; label: string }[];
showLanguageSelector?: boolean;
}
const buildInitialValues = (user: RouterOutputs["user"]["getById"]): FormValues => ({
name: user.name ?? "",
email: user.email ?? "",
homeBoardId: user.homeBoardId,
mobileHomeBoardId: user.mobileHomeBoardId,
defaultSearchEngineId: user.defaultSearchEngineId,
openInNewTab: user.openSearchInNewTab,
ddgBangsEnabled: user.ddgBangs,
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
pingIconsEnabled: user.pingIconsEnabled,
});
export const UserGeneralSettingsForm = ({
user,
boardsData,
searchEnginesData,
showLanguageSelector = false,
}: UserGeneralSettingsFormProps) => {
const t = useI18n();
const tGeneral = useScopedI18n("management.page.user.setting.general");
const isCredentialsUser = user.provider === "credentials";
const editProfileMutation = clientApi.user.editProfile.useMutation();
const changeHomeBoardsMutation = clientApi.user.changeHomeBoards.useMutation();
const changeSearchPreferencesMutation = clientApi.user.changeSearchPreferences.useMutation();
const changeFirstDayOfWeekMutation = clientApi.user.changeFirstDayOfWeek.useMutation();
const changePingIconsEnabledMutation = clientApi.user.changePingIconsEnabled.useMutation();
const initialValues = buildInitialValues(user);
const initialValuesRef = useRef(initialValues);
const form = useZodForm(userGeneralSettingsSchema, {
initialValues,
});
const isDirtyRef = useRef(false);
isDirtyRef.current = form.isDirty();
const weekDays = useMemo(() => dayjs.weekdays(false), []);
const mutations = [
editProfileMutation,
changeHomeBoardsMutation,
changeSearchPreferencesMutation,
changeFirstDayOfWeekMutation,
changePingIconsEnabledMutation,
];
const isPending = mutations.some((m) => m.isPending);
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (env.NODE_ENV === "development") return;
if (isDirtyRef.current) e.preventDefault();
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, []);
const handleSubmitAsync = async (values: FormValues) => {
const parsed = userGeneralSettingsSchema.safeParse(values);
if (!parsed.success) return;
const defaults = initialValuesRef.current;
const changed = <K extends keyof FormValues>(...fields: K[]) =>
fields.some((f) => (f === "email" ? (values[f] || null) !== (defaults[f] || null) : values[f] !== defaults[f]));
const saveActions: { when: boolean; action: () => Promise<unknown> }[] = [
{
when: isCredentialsUser && changed("name", "email"),
action: () =>
editProfileMutation.mutateAsync({ id: user.id, name: parsed.data.name, email: parsed.data.email ?? "" }),
},
{
when: changed("homeBoardId", "mobileHomeBoardId"),
action: () =>
changeHomeBoardsMutation.mutateAsync({
userId: user.id,
homeBoardId: parsed.data.homeBoardId,
mobileHomeBoardId: parsed.data.mobileHomeBoardId,
}),
},
{
when: changed("defaultSearchEngineId", "openInNewTab", "ddgBangsEnabled"),
action: () =>
changeSearchPreferencesMutation.mutateAsync({
userId: user.id,
defaultSearchEngineId: parsed.data.defaultSearchEngineId,
openInNewTab: parsed.data.openInNewTab,
ddgBangsEnabled: parsed.data.ddgBangsEnabled,
}),
},
{
when: changed("firstDayOfWeek"),
action: () =>
changeFirstDayOfWeekMutation.mutateAsync({ id: user.id, firstDayOfWeek: parsed.data.firstDayOfWeek }),
},
{
when: changed("pingIconsEnabled"),
action: () =>
changePingIconsEnabledMutation.mutateAsync({ id: user.id, pingIconsEnabled: parsed.data.pingIconsEnabled }),
},
];
const promises = saveActions.filter((s) => s.when).map((s) => s.action());
if (promises.length === 0) return;
try {
await Promise.all(promises);
const newValues = { ...parsed.data, email: parsed.data.email ?? "" };
initialValuesRef.current = newValues;
form.setInitialValues(newValues);
form.resetDirty();
await revalidatePathActionAsync(`/manage/users/${user.id}`);
showSuccessNotification({
title: t("common.notification.update.success"),
message: t("common.notification.update.success"),
});
} catch {
showErrorNotification({
title: t("common.notification.update.error"),
message: t("common.notification.update.error"),
});
}
};
const handleDiscard = () => {
form.setValues(initialValuesRef.current);
form.resetDirty();
};
const firstDayOfWeekInputProps = form.getInputProps("firstDayOfWeek");
const firstDayOfWeekOnChange = firstDayOfWeekInputProps.onChange as (value: number) => void;
const firstDayOfWeekValue = (firstDayOfWeekInputProps.value as number).toString();
return (
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
<Stack gap="lg">
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="lg" verticalSpacing="lg">
<Card withBorder bg="transparent">
<Stack gap="md">
<Stack gap={2}>
<Title order={3}>{t("user.name")}</Title>
{!isCredentialsUser && (
<Text c="dimmed" size="sm">
{t("management.page.user.fieldsDisabledExternalProvider")}
</Text>
)}
</Stack>
<Divider />
<TextInput
disabled={!isCredentialsUser}
label={t("user.field.username.label")}
withAsterisk
{...form.getInputProps("name")}
/>
<TextInput
disabled={!isCredentialsUser}
label={t("user.field.email.label")}
{...form.getInputProps("email")}
/>
</Stack>
</Card>
<Card withBorder bg="transparent">
<Stack gap="md">
<Stack gap={2}>
<Title order={3}>{tGeneral("item.board.title")}</Title>
</Stack>
<Divider />
<BoardSelect
label={tGeneral("item.board.type.general")}
clearable
boards={boardsData}
w="100%"
withinPortal
{...form.getInputProps("homeBoardId")}
/>
<BoardSelect
label={tGeneral("item.board.type.mobile")}
clearable
boards={boardsData}
w="100%"
withinPortal
{...form.getInputProps("mobileHomeBoardId")}
/>
</Stack>
</Card>
<Card withBorder bg="transparent">
<Stack gap="md">
<Stack gap={2}>
<Title order={3}>{tGeneral("item.search")}</Title>
</Stack>
<Divider />
<Select
label={t("user.field.defaultSearchEngine.label")}
w="100%"
data={searchEnginesData}
comboboxProps={{ withinPortal: true }}
{...form.getInputProps("defaultSearchEngineId")}
/>
<Switch
label={t("user.field.openSearchInNewTab.label")}
{...form.getInputProps("openInNewTab", { type: "checkbox" })}
/>
<Switch
label={t("user.field.ddgBangs.label")}
{...form.getInputProps("ddgBangsEnabled", { type: "checkbox" })}
/>
</Stack>
</Card>
<Card withBorder bg="transparent">
<Stack gap="md">
<Stack gap={2}>
<Title order={3}>{tGeneral("item.language")}</Title>
</Stack>
<Divider />
{showLanguageSelector && <CurrentLanguageCombobox withinPortal />}
<Title order={4}>{tGeneral("item.firstDayOfWeek")}</Title>
<Radio.Group
{...firstDayOfWeekInputProps}
value={firstDayOfWeekValue}
onChange={(nextValue: string) => firstDayOfWeekOnChange(parseInt(nextValue, 10))}
>
<Group mt="xs" wrap="wrap">
{FIRST_DAY_OPTIONS.map(({ value: dayValue, labelKey }) => (
<Radio key={dayValue} value={dayValue.toString()} label={weekDays[labelKey]} />
))}
</Group>
</Radio.Group>
<Divider my="xs" />
<Title order={4}>{tGeneral("item.accessibility")}</Title>
<Switch
label={t("user.field.pingIconsEnabled.label")}
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
/>
</Stack>
</Card>
</SimpleGrid>
<div style={{ position: "sticky", bottom: 20 }}>
{form.isDirty() && (
<Card withBorder>
<Group justify="space-between" wrap="wrap">
<Text fw={500}>{t("common.unsavedChanges")}</Text>
<Group>
<Button disabled={isPending} variant="default" onClick={handleDiscard}>
{t("common.action.discard")}
</Button>
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
{t("common.action.saveChanges")}
</Button>
</Group>
</Group>
</Card>
)}
</div>
</Stack>
</form>
);
};
@@ -2,7 +2,7 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { Box, Button, FileButton, Menu, UnstyledButton } from "@mantine/core"; import { Box, Button, FileButton, Menu, UnstyledButton } from "@mantine/core";
import { useDisclosure, useMediaQuery } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { IconPencil, IconPhotoEdit, IconPhotoX } from "@tabler/icons-react"; import { IconPencil, IconPhotoEdit, IconPhotoX } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
@@ -25,7 +25,6 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
}, },
}); });
const [opened, { toggle }] = useDisclosure(false); const [opened, { toggle }] = useDisclosure(false);
const isMobile = useMediaQuery("(max-width: 48em)");
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const t = useI18n(); const t = useI18n();
const tManageAvatar = useScopedI18n("user.action.manageAvatar"); const tManageAvatar = useScopedI18n("user.action.manageAvatar");
@@ -95,11 +94,11 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
}, [mutate, user.id, openConfirmModal, tManageAvatar]); }, [mutate, user.id, openConfirmModal, tManageAvatar]);
return ( return (
<Box pos="relative" display="flex" style={{ justifyContent: isMobile ? "center" : undefined }}> <Box pos="relative">
<Menu opened={opened} keepMounted onChange={toggle} position="bottom-start" withArrow withinPortal> <Menu opened={opened} keepMounted onChange={toggle} position="bottom-start" withArrow>
<Menu.Target> <Menu.Target>
<UnstyledButton onClick={toggle}> <UnstyledButton onClick={toggle}>
<UserAvatar user={user} size={isMobile ? 120 : 200} /> <UserAvatar user={user} size={200} />
<Button <Button
component="div" component="div"
pos="absolute" pos="absolute"
@@ -1,53 +0,0 @@
"use client";
import { Badge, Button, Group, Stack, Text } from "@mantine/core";
import { IconRefresh } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
export const ResetTours = () => {
const t = useScopedI18n("management.page.user.setting.general.item.onboardingTours");
const utils = clientApi.useUtils();
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
const { mutate: resetTours, isPending } = clientApi.user.resetTours.useMutation({
onSuccess() {
showSuccessNotification({
title: t("resetSuccess.title"),
message: t("resetSuccess.message"),
});
void utils.user.getTourStatus.invalidate();
},
});
const tours = [
{ key: "management" as const, completed: tourStatus?.completedManageTour ?? false },
{ key: "dashboard" as const, completed: tourStatus?.completedBoardTour ?? false },
];
return (
<Stack gap="xs">
<Group gap="xs">
{tours.map(({ key, completed }) => (
<Badge key={key} color={completed ? "green" : "gray"} variant="light">
{t(`name.${key}`)}: {completed ? t("status.completed") : t("status.notStarted")}
</Badge>
))}
</Group>
<Text size="sm" c="dimmed">
{t("description")}
</Text>
<Button
leftSection={<IconRefresh size={16} />}
variant="light"
size="sm"
w="fit-content"
onClick={() => resetTours()}
loading={isPending}
>
{t("reset")}
</Button>
</Stack>
);
};
@@ -18,7 +18,6 @@ import { FirstDayOfWeek } from "./_components/_first-day-of-week";
import { PingIconsEnabled } from "./_components/_ping-icons-enabled"; import { PingIconsEnabled } from "./_components/_ping-icons-enabled";
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form"; import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
import { UserProfileForm } from "./_components/_profile-form"; import { UserProfileForm } from "./_components/_profile-form";
import { ResetTours } from "./_components/_reset-tours";
interface Props { interface Props {
params: Promise<{ params: Promise<{
@@ -119,13 +118,6 @@ export default async function EditUserPage(props: Props) {
<PingIconsEnabled user={user} /> <PingIconsEnabled user={user} />
</Stack> </Stack>
{session?.user.id === user.id && (
<Stack mb="lg">
<Title order={2}>{tGeneral("item.onboardingTours.title")}</Title>
<ResetTours />
</Stack>
)}
<DangerZoneRoot> <DangerZoneRoot>
<DangerZoneItem <DangerZoneItem
label={t("user.action.delete.label")} label={t("user.action.delete.label")}
@@ -1,22 +1,24 @@
"use client"; "use client";
import { useMemo } from "react"; import { useMemo } from "react";
import { Anchor, Group, Text, Tooltip } from "@mantine/core"; import { Anchor, Button, Group, Text, Title, Tooltip } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react"; import { IconCheck } from "@tabler/icons-react";
import type { MRT_ColumnDef } from "mantine-react-table"; import type { MRT_ColumnDef } from "mantine-react-table";
import { MantineReactTable } from "mantine-react-table"; import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { Link, UserAvatar } from "@homarr/ui"; import { Link, UserAvatar } from "@homarr/ui";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
interface UserListComponentProps { interface UserListComponentProps {
initialUserList: RouterOutputs["user"]["getAll"]; initialUserList: RouterOutputs["user"]["getAll"];
credentialsProviderEnabled: boolean;
} }
export const UserListComponent = ({ initialUserList }: UserListComponentProps) => { export const UserListComponent = ({ initialUserList, credentialsProviderEnabled }: UserListComponentProps) => {
const tUserList = useScopedI18n("management.page.user.list");
const t = useI18n(); const t = useI18n();
const { data, isLoading } = clientApi.user.getAll.useQuery(undefined, { const { data, isLoading } = clientApi.user.getAll.useQuery(undefined, {
initialData: initialUserList, initialData: initialUserList,
@@ -67,10 +69,21 @@ export const UserListComponent = ({ initialUserList }: UserListComponentProps) =
enableFullScreenToggle: false, enableFullScreenToggle: false,
layoutMode: "grid-no-grow", layoutMode: "grid-no-grow",
getRowId: (row) => row.id, getRowId: (row) => row.id,
renderTopToolbarCustomActions: () =>
credentialsProviderEnabled ? (
<Button component={Link} href="/manage/users/create">
{t("management.page.user.create.title")}
</Button>
) : null,
state: { state: {
isLoading, isLoading,
}, },
}); });
return <MantineReactTable table={table} />; return (
<>
<Title mb="md">{tUserList("title")}</Title>
<MantineReactTable table={table} />
</>
);
}; };
@@ -1,7 +1,20 @@
"use client"; "use client";
import { startTransition, useCallback, useState } from "react"; import { startTransition, useCallback, useState } from "react";
import { Badge, Button, Card, Group, Stack, Stepper, Table, Text, TextInput, Title, Tooltip } from "@mantine/core"; import {
Badge,
Button,
Card,
Group,
PasswordInput,
Stack,
Stepper,
Table,
Text,
TextInput,
Title,
Tooltip,
} from "@mantine/core";
import { useListState } from "@mantine/hooks"; import { useListState } from "@mantine/hooks";
import { IconPlus, IconUserCheck } from "@tabler/icons-react"; import { IconPlus, IconUserCheck } from "@tabler/icons-react";
import { z } from "zod/v4"; import { z } from "zod/v4";
@@ -11,11 +24,10 @@ import type { GroupPermissionKey } from "@homarr/definitions";
import { everyoneGroup, groupPermissions } from "@homarr/definitions"; import { everyoneGroup, groupPermissions } from "@homarr/definitions";
import type { IsValid } from "@homarr/form"; import type { IsValid } from "@homarr/form";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { UserCreatePasswordFields } from "@homarr/forms-collection";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications"; import { showErrorNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui"; import { CustomPasswordInput, UserAvatar } from "@homarr/ui";
import { createCustomErrorParams } from "@homarr/validation/form/i18n"; import { createCustomErrorParams } from "@homarr/validation/form/i18n";
import { userPasswordSchema } from "@homarr/validation/user"; import { userPasswordSchema } from "@homarr/validation/user";
@@ -137,7 +149,7 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
color={!generalForm.isValid() ? "red" : undefined} color={!generalForm.isValid() ? "red" : undefined}
> >
<form> <form>
<Card p="xl"> <Card p="xl" shadow="md" withBorder>
<Stack gap="md"> <Stack gap="md">
<TextInput <TextInput
label={tUserField("username.label")} label={tUserField("username.label")}
@@ -153,19 +165,27 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
</Stepper.Step> </Stepper.Step>
<Stepper.Step label={t("step.security.label")} allowStepSelect={false} allowStepClick={false}> <Stepper.Step label={t("step.security.label")} allowStepSelect={false} allowStepClick={false}>
<form> <form>
<Card p="xl"> <Card p="xl" shadow="md" withBorder>
<Stack gap="md"> <Stack gap="md">
<UserCreatePasswordFields <CustomPasswordInput
withPasswordRequirements
label={tUserField("password.label")}
variant="filled" variant="filled"
passwordInputProps={securityForm.getInputProps("password")} withAsterisk
confirmPasswordInputProps={securityForm.getInputProps("confirmPassword")} {...securityForm.getInputProps("password")}
/>
<PasswordInput
label={tUserField("passwordConfirm.label")}
variant="filled"
withAsterisk
{...securityForm.getInputProps("confirmPassword")}
/> />
</Stack> </Stack>
</Card> </Card>
</form> </form>
</Stepper.Step> </Stepper.Step>
<Stepper.Step label={t("step.groups.label")} allowStepSelect={false} allowStepClick={false}> <Stepper.Step label={t("step.groups.label")} allowStepSelect={false} allowStepClick={false}>
<Card p="xl"> <Card p="xl" shadow="md" withBorder>
<GroupsForm <GroupsForm
initialGroups={initialGroups} initialGroups={initialGroups}
addGroup={(groupId) => addGroup={(groupId) =>
@@ -178,20 +198,20 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
</Card> </Card>
</Stepper.Step> </Stepper.Step>
<Stepper.Step label={t("step.review.label")} allowStepSelect={false} allowStepClick={false}> <Stepper.Step label={t("step.review.label")} allowStepSelect={false} allowStepClick={false}>
<Card p="xl"> <Card p="xl" shadow="md" withBorder>
<Stack maw={300} align="center" mx="auto"> <Stack maw={300} align="center" mx="auto">
<UserAvatar <UserAvatar
size="xl" size="xl"
user={{ name: generalForm.values.username, email: generalForm.values.email ?? null, image: null }} user={{ name: generalForm.values.username, email: generalForm.values.email ?? null, image: null }}
/> />
<Text fw={700} size="xl"> <Text tt="uppercase" fw="bolder" size="xl">
{generalForm.values.username} {generalForm.values.username}
</Text> </Text>
</Stack> </Stack>
</Card> </Card>
</Stepper.Step> </Stepper.Step>
<Stepper.Completed> <Stepper.Completed>
<Card p="xl"> <Card p="xl" shadow="md" withBorder>
<Stack align="center" maw={300} mx="auto"> <Stack align="center" maw={300} mx="auto">
<IconUserCheck size="3rem" /> <IconUserCheck size="3rem" />
<Title order={2}>{t("step.completed.title")}</Title> <Title order={2}>{t("step.completed.title")}</Title>
@@ -25,7 +25,7 @@ export const StepperNavigationComponent = ({
}: StepperNavigationComponentProps) => { }: StepperNavigationComponentProps) => {
const t = useI18n(); const t = useI18n();
return ( return (
<Card> <Card shadow="md" withBorder>
{!isComplete ? ( {!isComplete ? (
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Button <Button
@@ -101,7 +101,7 @@ export const SaveAffix = ({ groupId }: SaveAffixProps) => {
<div style={{ position: "sticky", bottom: 20 }}> <div style={{ position: "sticky", bottom: 20 }}>
<Transition transition="slide-up" mounted={form.isDirty()}> <Transition transition="slide-up" mounted={form.isDirty()}>
{(transitionStyles) => ( {(transitionStyles) => (
<Card style={transitionStyles}> <Card style={transitionStyles} withBorder>
<Group justify="space-between"> <Group justify="space-between">
<Text fw={500}>{tForm("unsavedChanges")}</Text> <Text fw={500}>{tForm("unsavedChanges")}</Text>
<Group> <Group>
@@ -68,6 +68,7 @@ const PermissionCard = async ({ group, isDanger }: PermissionCardProps) => {
return ( return (
<Card <Card
p="md" p="md"
withBorder
style={{ style={{
borderColor: isDanger ? "var(--mantine-color-red-8)" : undefined, borderColor: isDanger ? "var(--mantine-color-red-8)" : undefined,
}} }}
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { TextInput } from "@mantine/core"; import { Group, TextInput } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react"; import { IconSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
@@ -33,20 +33,29 @@ export const GroupsList = ({ groups }: GroupsListProps) => {
return ( return (
<> <>
<TextInput <Group justify="space-between">
leftSection={<IconSearch size={20} stroke={1.5} />} <TextInput
value={search} leftSection={<IconSearch size={20} stroke={1.5} />}
onChange={(event) => setSearch(event.currentTarget.value)} value={search}
placeholder={`${t("group.search")}...`} onChange={(event) => setSearch(event.currentTarget.value)}
/> placeholder={`${t("group.search")}...`}
style={{ flex: 1 }}
/>
<AddGroup />
</Group>
<GroupsTable groups={filteredGroups} initialGroupIds={initialGroupIds} hasFilter={search.length !== 0} /> <GroupsTable groups={filteredGroups} initialGroupIds={initialGroupIds} hasFilter={search.length !== 0} />
</> </>
); );
}; };
export const AddGroupButton = () => { const AddGroup = () => {
const t = useI18n(); const t = useI18n();
const { openModal } = useModalAction(AddGroupModal); const { openModal } = useModalAction(AddGroupModal);
return <MobileAffixButton onClick={() => openModal()}>{t("group.action.create.label")}</MobileAffixButton>; const handleAddGroup = useCallback(() => {
openModal();
}, [openModal]);
return <MobileAffixButton onClick={handleAddGroup}>{t("group.action.create.label")}</MobileAffixButton>;
}; };
@@ -259,7 +259,7 @@ const SaveAffix = ({ visible, isPending, onDiscard, onSave }: SaveAffixProps) =>
<div style={{ position: "sticky", bottom: 20 }}> <div style={{ position: "sticky", bottom: 20 }}>
<Transition transition="slide-up" mounted={visible}> <Transition transition="slide-up" mounted={visible}>
{(transitionStyles) => ( {(transitionStyles) => (
<Card style={transitionStyles}> <Card style={transitionStyles} withBorder>
<Group justify="space-between"> <Group justify="space-between">
<Text fw={500}>{t("common.unsavedChanges")}</Text> <Text fw={500}>{t("common.unsavedChanges")}</Text>
<Group> <Group>
@@ -1,5 +1,5 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { Card, Group, Stack, Text, ThemeIcon, UnstyledButton } from "@mantine/core"; import { Card, Group, Stack, Text, ThemeIcon, Title, UnstyledButton } from "@mantine/core";
import { IconChevronRight, IconUsersGroup } from "@tabler/icons-react"; import { IconChevronRight, IconUsersGroup } from "@tabler/icons-react";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
@@ -8,8 +8,9 @@ import { everyoneGroup } from "@homarr/definitions";
import { getI18n } from "@homarr/translation/server"; import { getI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui"; import { Link } from "@homarr/ui";
import { ManagePageLayout } from "~/components/manage/manage-page-layout"; import { ManageContainer } from "~/components/manage/manage-container";
import { AddGroupButton, GroupsList } from "./_client"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { GroupsList } from "./_client";
import classes from "./groups.module.css"; import classes from "./groups.module.css";
export default async function GroupsListPage() { export default async function GroupsListPage() {
@@ -25,8 +26,11 @@ export default async function GroupsListPage() {
const groupsWithoutEveryone = groups.filter((group) => group.name !== everyoneGroup); const groupsWithoutEveryone = groups.filter((group) => group.name !== everyoneGroup);
return ( return (
<ManagePageLayout title={t("group.title")} primaryAction={<AddGroupButton />} floatingPrimaryAction> <ManageContainer size="xl">
<DynamicBreadcrumb />
<Stack> <Stack>
<Title>{t("group.title")}</Title>
{dbEveryoneGroup && ( {dbEveryoneGroup && (
<UnstyledButton component={Link} href={`/manage/users/groups/${dbEveryoneGroup.id}`}> <UnstyledButton component={Link} href={`/manage/users/groups/${dbEveryoneGroup.id}`}>
<Card className={classes.everyoneGroup}> <Card className={classes.everyoneGroup}>
@@ -49,6 +53,6 @@ export default async function GroupsListPage() {
<GroupsList groups={groupsWithoutEveryone} /> <GroupsList groups={groupsWithoutEveryone} />
</Stack> </Stack>
</ManagePageLayout> </ManageContainer>
); );
} }
@@ -1,20 +0,0 @@
"use client";
import { Button } from "@mantine/core";
import { useModalAction } from "@homarr/modals";
import { InviteCreateModal } from "@homarr/modals-collection";
import { useScopedI18n } from "@homarr/translation/client";
import { MANAGE_ACTION_BUTTON_MIN_WIDTH } from "~/components/manage/manage-page.constants";
export const InviteCreateButton = () => {
const t = useScopedI18n("management.page.user.invite");
const { openModal } = useModalAction(InviteCreateModal);
return (
<Button miw={MANAGE_ACTION_BUTTON_MIN_WIDTH} onClick={() => openModal()}>
{t("action.new.title")}
</Button>
);
};
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { ActionIcon } from "@mantine/core"; import { ActionIcon, Button, Title } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react"; import { IconTrash } from "@tabler/icons-react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime"; import relativeTime from "dayjs/plugin/relativeTime";
@@ -10,7 +10,8 @@ import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useConfirmModal } from "@homarr/modals"; import { useConfirmModal, useModalAction } from "@homarr/modals";
import { InviteCreateModal } from "@homarr/modals-collection";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
@@ -64,6 +65,7 @@ export const InviteListComponent = ({ initialInvites }: InviteListComponentProps
enableFullScreenToggle: false, enableFullScreenToggle: false,
layoutMode: "grid-no-grow", layoutMode: "grid-no-grow",
getRowId: (row) => row.id, getRowId: (row) => row.id,
renderTopToolbarCustomActions: RenderTopToolbarCustomActions,
state: { state: {
isLoading, isLoading,
}, },
@@ -72,7 +74,22 @@ export const InviteListComponent = ({ initialInvites }: InviteListComponentProps
}, },
}); });
return <MantineReactTable table={table} />; return (
<>
<Title mb="md">{t("title")}</Title>
<MantineReactTable table={table} />
</>
);
};
const RenderTopToolbarCustomActions = () => {
const t = useScopedI18n("management.page.user.invite");
const { openModal } = useModalAction(InviteCreateModal);
const handleNewInvite = useCallback(() => {
openModal();
}, [openModal]);
return <Button onClick={handleNewInvite}>{t("action.new.title")}</Button>;
}; };
const RenderRowActions = ({ row }: { row: MRT_Row<RouterOutputs["invite"]["getAll"][number]> }) => { const RenderRowActions = ({ row }: { row: MRT_Row<RouterOutputs["invite"]["getAll"][number]> }) => {
@@ -3,11 +3,8 @@ import { notFound } from "next/navigation";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server"; import { isProviderEnabled } from "@homarr/auth/server";
import { getScopedI18n } from "@homarr/translation/server";
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { InviteCreateButton } from "./_components/invite-create-button";
import { InviteListComponent } from "./_components/invite-list"; import { InviteListComponent } from "./_components/invite-list";
export default async function InvitesOverviewPage() { export default async function InvitesOverviewPage() {
@@ -20,20 +17,11 @@ export default async function InvitesOverviewPage() {
return notFound(); return notFound();
} }
const t = await getScopedI18n("management.page.user.invite");
const initialInvites = await api.invite.getAll(); const initialInvites = await api.invite.getAll();
return ( return (
<ManagePageLayout <>
title={t("title")} <DynamicBreadcrumb />
primaryAction={
<ManageMobilePrimaryAction>
<InviteCreateButton />
</ManageMobilePrimaryAction>
}
floatingPrimaryAction
>
<InviteListComponent initialInvites={initialInvites} /> <InviteListComponent initialInvites={initialInvites} />
</ManagePageLayout> </>
); );
} }
@@ -4,11 +4,8 @@ import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server"; import { isProviderEnabled } from "@homarr/auth/server";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
import { TourTarget } from "~/components/layout/header/tour-target"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { createMetaTitle } from "~/metadata"; import { createMetaTitle } from "~/metadata";
import { UserListComponent } from "./_components/user-list"; import { UserListComponent } from "./_components/user-list";
@@ -30,27 +27,13 @@ export default async function UsersPage() {
return notFound(); return notFound();
} }
const t = await getScopedI18n("management.page.user");
const userList = await api.user.getAll(); const userList = await api.user.getAll();
const credentialsProviderEnabled = isProviderEnabled("credentials"); const credentialsProviderEnabled = isProviderEnabled("credentials");
return ( return (
<ManagePageLayout <>
title={t("list.title")} <DynamicBreadcrumb />
primaryAction={ <UserListComponent initialUserList={userList} credentialsProviderEnabled={credentialsProviderEnabled} />
credentialsProviderEnabled ? ( </>
<TourTarget id="manage-users-create">
<MobileAffixButton component={Link} href="/manage/users/create">
{t("create.title")}
</MobileAffixButton>
</TourTarget>
) : undefined
}
floatingPrimaryAction={credentialsProviderEnabled}
>
<TourTarget id="manage-users-list">
<UserListComponent initialUserList={userList} />
</TourTarget>
</ManagePageLayout>
); );
} }
@@ -99,7 +99,7 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
return ( return (
<> <>
<Card w={dimensions.width} h={dimensions.height} p={dimensions.height >= 96 ? undefined : 4}> <Card withBorder w={dimensions.width} h={dimensions.height} p={dimensions.height >= 96 ? undefined : 4}>
<QueryErrorResetBoundary> <QueryErrorResetBoundary>
{({ reset }) => ( {({ reset }) => (
<ErrorBoundary <ErrorBoundary
@@ -123,35 +123,17 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
</QueryErrorResetBoundary> </QueryErrorResetBoundary>
</Card> </Card>
<Affix bottom={12} right={72}> <Affix bottom={12} right={72}>
<ActionIcon <ActionIcon size={48} variant="default" radius="xl" onClick={handleOpenEditWidgetModal}>
size={48}
variant="default"
radius="xl"
onClick={handleOpenEditWidgetModal}
aria-label={t("common.action.edit")}
>
<IconPencil size={24} /> <IconPencil size={24} />
</ActionIcon> </ActionIcon>
</Affix> </Affix>
<Affix bottom={12} right={72 + 60}> <Affix bottom={12} right={72 + 60}>
<ActionIcon <ActionIcon size={48} variant="default" radius="xl" onClick={toggleEditMode}>
size={48}
variant="default"
radius="xl"
onClick={toggleEditMode}
aria-label={editMode ? t("widgetPreview.toggle.disabled") : t("widgetPreview.toggle.enabled")}
>
{editMode ? <IconToggleLeft size={24} /> : <IconToggleRight size={24} />} {editMode ? <IconToggleLeft size={24} /> : <IconToggleRight size={24} />}
</ActionIcon> </ActionIcon>
</Affix> </Affix>
<Affix bottom={12} right={72 + 120}> <Affix bottom={12} right={72 + 120}>
<ActionIcon <ActionIcon size={48} variant="default" radius="xl" onClick={openDimensionsModal}>
size={48}
variant="default"
radius="xl"
onClick={openDimensionsModal}
aria-label={t("widgetPreview.dimensions.title")}
>
<IconDimensions size={24} /> <IconDimensions size={24} />
</ActionIcon> </ActionIcon>
</Affix> </Affix>
@@ -42,6 +42,7 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
item.advancedOptions.customCssClasses.join(" "), item.advancedOptions.customCssClasses.join(" "),
)} )}
radius={board.itemRadius} radius={board.itemRadius}
withBorder
styles={{ styles={{
root: { root: {
"--opacity": board.opacity / 100, "--opacity": board.opacity / 100,
@@ -85,7 +85,6 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
), ),
integrationSupport: "supportedIntegrations" in currentDefinition, integrationSupport: "supportedIntegrations" in currentDefinition,
settings, settings,
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
}, },
{ {
title(t) { title(t) {
@@ -106,17 +105,9 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
}; };
return ( return (
<Menu withinPortal position="right-start" arrowPosition="center"> <Menu withinPortal withArrow position="right-start" arrowPosition="center">
<Menu.Target> <Menu.Target>
<ActionIcon <ActionIcon variant="default" radius={"xl"} pos="absolute" top={offset} right={offset} style={{ zIndex: 10 }}>
variant="default"
radius={"xl"}
pos="absolute"
top={offset}
right={offset}
style={{ zIndex: 10 }}
aria-label={tItem("menu.label.settings")}
>
<IconDotsVertical size={"1rem"} /> <IconDotsVertical size={"1rem"} />
</ActionIcon> </ActionIcon>
</Menu.Target> </Menu.Target>
@@ -1,11 +1,11 @@
import { useCallback, useRef } from "react"; import { useCallback, useRef } from "react";
import { Grid, NumberInput, Stack } from "@mantine/core"; import { Button, Grid, Group, NumberInput, Stack } from "@mantine/core";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import type { GridStack } from "@homarr/gridstack"; import type { GridStack } from "@homarr/gridstack";
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals"; import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { Item, SectionItem } from "~/app/[locale]/boards/_types"; import type { Item, SectionItem } from "~/app/[locale]/boards/_types";
@@ -16,7 +16,9 @@ interface InnerProps {
} }
export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) => { export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) => {
const tCommon = useScopedI18n("common");
const t = useI18n(); const t = useI18n();
// Keep track of the maximum width based on the x offset
const maxWidthRef = useRef(innerProps.columnCount - innerProps.item.xOffset); const maxWidthRef = useRef(innerProps.columnCount - innerProps.item.xOffset);
const form = useZodForm( const form = useZodForm(
z.object({ z.object({
@@ -36,6 +38,7 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
height: innerProps.item.height, height: innerProps.item.height,
}, },
onValuesChange(values, previous) { onValuesChange(values, previous) {
// Update the maximum width when the x offset changes
if (values.xOffset !== previous.xOffset) { if (values.xOffset !== previous.xOffset) {
maxWidthRef.current = innerProps.columnCount - values.xOffset; maxWidthRef.current = innerProps.columnCount - values.xOffset;
} }
@@ -67,7 +70,6 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
<Grid.Col span={{ base: 12, md: 6 }}> <Grid.Col span={{ base: 12, md: 6 }}>
<NumberInput <NumberInput
label={t("item.moveResize.field.xOffset.label")} label={t("item.moveResize.field.xOffset.label")}
data-autofocus
min={0} min={0}
max={innerProps.columnCount - 1} max={innerProps.columnCount - 1}
{...form.getInputProps("xOffset")} {...form.getInputProps("xOffset")}
@@ -91,7 +93,12 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
<NumberInput label={t("item.moveResize.field.height.label")} min={1} {...form.getInputProps("height")} /> <NumberInput label={t("item.moveResize.field.height.label")} min={1} {...form.getInputProps("height")} />
</Grid.Col> </Grid.Col>
</Grid> </Grid>
<ModalFormFooter onCancel={actions.closeModal} /> <Group justify="end">
<Button variant="subtle" onClick={actions.closeModal}>
{tCommon("action.cancel")}
</Button>
<Button type="submit">{tCommon("action.saveChanges")}</Button>
</Group>
</Stack> </Stack>
</form> </form>
); );
@@ -99,5 +106,5 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
defaultTitle(t) { defaultTitle(t) {
return t("item.moveResize.title"); return t("item.moveResize.title");
}, },
size: modalSizeForm, size: "lg",
}); });
@@ -1,14 +1,14 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Avatar, Box, Button, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core"; import { Avatar, Button, Card, Center, Grid, Group, Input, Stack, Text, Tooltip } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { createId, objectEntries } from "@homarr/common"; import { createId, objectEntries } from "@homarr/common";
import { getIconUrl, getIntegrationName } from "@homarr/definitions"; import { getIconUrl, getIntegrationName } from "@homarr/definitions";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals"; import { createModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings"; import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import { WidgetEditModal } from "@homarr/widgets/modals"; import { WidgetEditModal } from "@homarr/widgets/modals";
@@ -86,31 +86,32 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
}; };
return ( return (
<SelectGridLayout <Stack>
search={search} <Input
onSearchChange={setSearch} value={search}
placeholder={`${t("item.create.search")}...`} onChange={(event) => setSearch(event.currentTarget.value)}
onSearchKeyDown={(event) => { leftSection={<IconSearch />}
if (event.key === "Enter" && filteredItems.length === 1) { placeholder={`${t("item.create.search")}...`}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion data-autofocus
handleAdd(filteredItems[0]!.kind); onKeyDown={(event) => {
} // Add item if there is only one item in the list and user presses Enter
}} if (event.key === "Enter" && filteredItems.length === 1) {
> // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
{filteredItems.map((item) => ( handleAdd(filteredItems[0]!.kind);
<WidgetItem key={item.kind} item={item} onSelect={() => handleAdd(item.kind)} /> }
))} }}
/>
{filteredItems.length === 0 && ( <Grid>
<Center p="xl"> {filteredItems.map((item) => (
<Text c="dimmed">{t("common.noResults")}</Text> <WidgetItem key={item.kind} item={item} onSelect={() => handleAdd(item.kind)} />
</Center> ))}
)} </Grid>
</SelectGridLayout> </Stack>
); );
}).withOptions({ }).withOptions({
defaultTitle: (t) => t("item.create.title"), defaultTitle: (t) => t("item.create.title"),
size: modalSizeSelect, size: "xl",
}); });
const WidgetItem = ({ const WidgetItem = ({
@@ -129,59 +130,47 @@ const WidgetItem = ({
const t = useI18n(); const t = useI18n();
return ( return (
<Card <Grid.Col span={{ xs: 12, sm: 4, md: 3 }}>
h={selectGridCardHeight} <Card h="100%">
withBorder <Stack justify="space-between" h="100%">
pos="relative" <Stack gap="xs">
style={{ overflow: "hidden", "--_hover-opacity": "0" }} <Center>
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")} <item.icon />
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")} </Center>
> <Text lh={1.2} style={{ whiteSpace: "normal" }} ta="center">
<Stack h="100%" gap="xs"> {item.name}
<Group gap="sm" wrap="nowrap" align="flex-start"> </Text>
<item.icon size={22} style={{ flexShrink: 0, marginTop: 2 }} /> <Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" ta="center" c="dimmed">
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}> {item.description}
{item.name} </Text>
</Text> </Stack>
</Group> <SupportedIntegrations mt="auto" integrations={item.supportedIntegrations} />
<Tooltip label={item.description} multiline w={250} disabled={!item.description}> <Button onClick={onSelect} variant="light" size="xs" radius="md" fullWidth>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}> {t(`item.create.addToBoard`)}
{item.description} </Button>
</Text> </Stack>
</Tooltip> </Card>
<SupportedIntegrations integrations={item.supportedIntegrations} /> </Grid.Col>
</Stack>
<Box
pos="absolute"
bottom={0}
left={0}
right={0}
p="xs"
style={{
opacity: "var(--_hover-opacity)",
transition: "opacity 150ms ease",
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
}}
>
<Button onClick={onSelect} variant="light" size="xs" fullWidth>
{t(`item.create.addToBoard`)}
</Button>
</Box>
</Card>
); );
}; };
const SupportedIntegrations = ({ integrations }: { integrations: IntegrationKind[] }) => { interface SupportedIntegrationsProps {
integrations: IntegrationKind[];
mt: string;
}
const SupportedIntegrations = ({ integrations, mt }: SupportedIntegrationsProps) => {
if (integrations.length === 0) { if (integrations.length === 0) {
return null; return null;
} }
// When there are 8 or more integrations, we show 6 and a "+X" avatar. Otherwise, we show all integrations.
const countToShow = integrations.length >= 8 ? 6 : 7; const countToShow = integrations.length >= 8 ? 6 : 7;
const moreCount = integrations.length - countToShow; const moreCount = integrations.length - countToShow;
return ( return (
<Group gap={2} mt="auto"> <Center mt={mt}>
<Tooltip.Group closeDelay={100}> <Tooltip.Group openDelay={300} closeDelay={100}>
<Group gap={2}> <Group gap={2}>
{integrations.slice(0, countToShow).map((integration) => ( {integrations.slice(0, countToShow).map((integration) => (
<Tooltip key={integration} label={getIntegrationName(integration)} withArrow> <Tooltip key={integration} label={getIntegrationName(integration)} withArrow>
@@ -206,6 +195,6 @@ const SupportedIntegrations = ({ integrations }: { integrations: IntegrationKind
)} )}
</Group> </Group>
</Tooltip.Group> </Tooltip.Group>
</Group> </Center>
); );
}; };
@@ -45,7 +45,13 @@ export const BoardCategorySection = ({ section }: Props) => {
}); });
return ( return (
<Card style={{ "--opacity": board.opacity / 100 }} radius={board.itemRadius} p={0} className={classes.itemCard}> <Card
style={{ "--opacity": board.opacity / 100 }}
radius={board.itemRadius}
withBorder
p={0}
className={classes.itemCard}
>
<Stack> <Stack>
<Group wrap="nowrap" gap="sm"> <Group wrap="nowrap" gap="sm">
<UnstyledButton w="100%" p="sm" onClick={toggle}> <UnstyledButton w="100%" p="sm" onClick={toggle}>
@@ -56,7 +62,7 @@ export const BoardCategorySection = ({ section }: Props) => {
</UnstyledButton> </UnstyledButton>
<CategoryMenu category={section} /> <CategoryMenu category={section} />
</Group> </Group>
<Collapse expanded={opened} p="sm" pt={0}> <Collapse in={opened} p="sm" pt={0}>
<GridStack section={section} /> <GridStack section={section} />
</Collapse> </Collapse>
</Stack> </Stack>
@@ -1,8 +1,8 @@
import { Stack, TextInput } from "@mantine/core"; import { Button, Group, Stack, TextInput } from "@mantine/core";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals"; import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
interface Category { interface Category {
@@ -36,10 +36,13 @@ export const CategoryEditModal = createModal<InnerProps>(({ actions, innerProps
> >
<Stack> <Stack>
<TextInput label={t("section.category.field.name.label")} data-autofocus {...form.getInputProps("name")} /> <TextInput label={t("section.category.field.name.label")} data-autofocus {...form.getInputProps("name")} />
<ModalFormFooter onCancel={actions.closeModal} submitLabel={innerProps.submitLabel} /> <Group justify="right">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button type="submit">{innerProps.submitLabel}</Button>
</Group>
</Stack> </Stack>
</form> </form>
); );
}).withOptions({ }).withOptions({});
size: modalSizeForm,
});
@@ -30,9 +30,9 @@ export const CategoryMenu = ({ category }: Props) => {
const t = useScopedI18n("section.category"); const t = useScopedI18n("section.category");
return ( return (
<Menu> <Menu withArrow>
<Menu.Target> <Menu.Target>
<ActionIcon mr="sm" variant="transparent" aria-label={t("menu.label.settings")}> <ActionIcon mr="sm" variant="transparent">
<IconDotsVertical size={20} /> <IconDotsVertical size={20} />
</ActionIcon> </ActionIcon>
</Menu.Target> </Menu.Target>
@@ -28,6 +28,7 @@ export const BoardDynamicSection = ({ section }: Props) => {
className={combineClasses(classes.itemCard, section.options.customCssClasses.join(" "))} className={combineClasses(classes.itemCard, section.options.customCssClasses.join(" "))}
w="100%" w="100%"
h="100%" h="100%"
withBorder
styles={{ styles={{
root: { root: {
overflow: "visible", overflow: "visible",
@@ -1,10 +1,10 @@
"use client"; "use client";
import { CloseButton, ColorInput, Stack, TextInput, useMantineTheme } from "@mantine/core"; import { Button, CloseButton, ColorInput, Group, Stack, TextInput, useMantineTheme } from "@mantine/core";
import type { z } from "zod/v4"; import type { z } from "zod/v4";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals"; import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { TextMultiSelect } from "@homarr/ui"; import { TextMultiSelect } from "@homarr/ui";
import { dynamicSectionOptionsSchema } from "@homarr/validation/shared"; import { dynamicSectionOptionsSchema } from "@homarr/validation/shared";
@@ -31,7 +31,7 @@ export const DynamicSectionEditModal = createModal<ModalProps>(({ actions, inner
})} })}
> >
<Stack> <Stack>
<TextInput label={t("section.dynamic.option.title.label")} data-autofocus {...form.getInputProps("title")} /> <TextInput label={t("section.dynamic.option.title.label")} {...form.getInputProps("title")} />
<TextMultiSelect <TextMultiSelect
label={t("section.dynamic.option.customCssClasses.label")} label={t("section.dynamic.option.customCssClasses.label")}
{...form.getInputProps("customCssClasses")} {...form.getInputProps("customCssClasses")}
@@ -48,7 +48,14 @@ export const DynamicSectionEditModal = createModal<ModalProps>(({ actions, inner
} }
{...form.getInputProps("borderColor")} {...form.getInputProps("borderColor")}
/> />
<ModalFormFooter onCancel={actions.closeModal} /> <Group justify="end">
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button type="submit">{t("common.action.saveChanges")}</Button>
</Group>
</Group>
</Stack> </Stack>
</form> </form>
); );
@@ -56,5 +63,5 @@ export const DynamicSectionEditModal = createModal<ModalProps>(({ actions, inner
defaultTitle(t) { defaultTitle(t) {
return t("item.edit.title"); return t("item.edit.title");
}, },
size: modalSizeForm, size: "lg",
}); });
@@ -43,17 +43,9 @@ export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSectionIt
}; };
return ( return (
<Menu withinPortal position="right-start" arrowPosition="center"> <Menu withinPortal withArrow position="right-start" arrowPosition="center">
<Menu.Target> <Menu.Target>
<ActionIcon <ActionIcon variant="default" radius={"xl"} pos="absolute" top={4} right={4} style={{ zIndex: 10 }}>
variant="default"
radius={"xl"}
pos="absolute"
top={4}
right={4}
style={{ zIndex: 10 }}
aria-label={tItem("menu.label.settings")}
>
<IconDotsVertical size={"1rem"} /> <IconDotsVertical size={"1rem"} />
</ActionIcon> </ActionIcon>
</Menu.Target> </Menu.Target>
@@ -1,6 +1,6 @@
import type { Ref, RefObject } from "react"; import type { RefObject } from "react";
import { createRef, useCallback, useEffect, useRef } from "react"; import { createRef, useCallback, useEffect, useRef } from "react";
import { useElementSize, useMergedRef } from "@mantine/hooks"; import { useElementSize } from "@mantine/hooks";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode"; import { useEditMode } from "@homarr/boards/edit-mode";
@@ -13,7 +13,7 @@ import { useSectionActions } from "../section-actions";
import { initializeGridstack } from "./init-gridstack"; import { initializeGridstack } from "./init-gridstack";
export interface UseGridstackRefs { export interface UseGridstackRefs {
wrapper: Ref<HTMLDivElement | null>; wrapper: RefObject<HTMLDivElement | null>;
items: RefObject<Record<string, RefObject<GridItemHTMLElement | null>>>; items: RefObject<Record<string, RefObject<GridItemHTMLElement | null>>>;
gridstack: RefObject<GridStack | null>; gridstack: RefObject<GridStack | null>;
} }
@@ -59,9 +59,8 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
const { moveAndResizeItem, moveItemToSection } = useItemActions(); const { moveAndResizeItem, moveItemToSection } = useItemActions();
const { moveAndResizeInnerSection, moveInnerSectionToSection } = useSectionActions(); const { moveAndResizeInnerSection, moveInnerSectionToSection } = useSectionActions();
const wrapperElementRef = useRef<HTMLDivElement | null>(null); // define reference for wrapper - is used to calculate the width of the wrapper
const { ref: measureWrapperRef, width, height } = useElementSize<HTMLDivElement>(); const { ref: wrapperRef, width, height } = useElementSize<HTMLDivElement>();
const wrapperRef = useMergedRef(measureWrapperRef, wrapperElementRef);
// references to the diffrent items contained in the gridstack // references to the diffrent items contained in the gridstack
const itemRefs = useRef<Record<string, RefObject<GridItemHTMLElement | null>>>({}); const itemRefs = useRef<Record<string, RefObject<GridItemHTMLElement | null>>>({});
// reference of the gridstack object for modifications after initialization // reference of the gridstack object for modifications after initialization
@@ -194,7 +193,7 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
itemIds, itemIds,
refs: { refs: {
items: itemRefs, items: itemRefs,
wrapper: wrapperElementRef, wrapper: wrapperRef,
gridstack: gridRef, gridstack: gridRef,
}, },
sectionColumnCount: columnCount, sectionColumnCount: columnCount,
@@ -277,7 +276,7 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
useCssVariableConfiguration({ useCssVariableConfiguration({
columnCount, columnCount,
gridRef, gridRef,
wrapperRef: wrapperElementRef, wrapperRef,
width, width,
height, height,
isDynamic: section.kind === "dynamic", isDynamic: section.kind === "dynamic",
@@ -294,7 +293,7 @@ export const useGridstack = (section: Omit<Section, "items">, itemIds: string[])
interface UseCssVariableConfiguration { interface UseCssVariableConfiguration {
gridRef: UseGridstackRefs["gridstack"]; gridRef: UseGridstackRefs["gridstack"];
wrapperRef: RefObject<HTMLDivElement | null>; wrapperRef: UseGridstackRefs["wrapper"];
width: number; width: number;
height: number; height: number;
columnCount: number; columnCount: number;
@@ -1,95 +0,0 @@
import { objectEntries } from "@homarr/common";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import {
featuredIntegrations,
getIntegrationName,
hiddenFromOnboarding,
integrationDefs,
integrationKinds,
} from "@homarr/definitions";
import { widgetImports } from "@homarr/widgets";
const buildWidgetsByIntegration = () => {
const map = Object.fromEntries(integrationKinds.map((kind) => [kind, [] as WidgetKind[]])) as Record<
IntegrationKind,
WidgetKind[]
>;
for (const [widgetKind, widget] of objectEntries(widgetImports)) {
const supported =
"supportedIntegrations" in widget.definition
? (widget.definition.supportedIntegrations as IntegrationKind[])
: [];
for (const kind of supported) {
if (kind in map) {
map[kind].push(widgetKind);
}
}
}
return map;
};
export const widgetsByIntegration = buildWidgetsByIntegration();
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",
search: "integration.category.search",
mediaTranscoding: "integration.category.mediaTranscoding",
networkController: "integration.category.networkController",
releasesProvider: "integration.category.releasesProvider",
notifications: "integration.category.notifications",
firewall: "integration.category.firewall",
timetable: "integration.category.timetable",
photoService: "integration.category.photoService",
notes: "integration.category.notes",
mediaMonitoring: "integration.category.mediaMonitoring",
speedtest: "integration.category.speedtest",
analytics: "integration.category.analytics",
};
export interface IntegrationGridItem {
kind: IntegrationKind;
name: string;
categories: string[];
widgets: WidgetKind[];
}
export const buildSortedIntegrations = (
options: { enableMockIntegration?: boolean; onboarding?: boolean } = {},
): IntegrationGridItem[] =>
integrationKinds
.filter((kind) => {
if (options.onboarding && hiddenFromOnboarding.has(kind)) return false;
if (options.onboarding && widgetsByIntegration[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: widgetsByIntegration[kind],
}))
.sort((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()));
Loaded 100 of 389 files, more files were not shown because too many files have changed in this diff. Show more