Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
088b4b58b8 | ||
|
|
65faf99d9c | ||
|
|
4b35c31ecc |
No files matched your search
@@ -1,5 +1,4 @@
|
||||
Dockerfile
|
||||
Dockerfile.dev
|
||||
.dockerignore
|
||||
node_modules
|
||||
**/node_modules
|
||||
@@ -15,10 +14,3 @@ apps/nextjs/.next
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.docker-data
|
||||
.docker-cache
|
||||
coverage
|
||||
.cursor
|
||||
agent-transcripts
|
||||
**/*.spec.ts
|
||||
**/*.test.ts
|
||||
@@ -33,7 +33,6 @@ body:
|
||||
options:
|
||||
# The below comment is used to insert a new version with on-release.yml
|
||||
#NEXT_VERSION#
|
||||
- 1.64.0
|
||||
- 1.62.0
|
||||
- 1.61.0
|
||||
- 1.60.1-beta.1
|
||||
|
||||
@@ -72,7 +72,3 @@ en.d.json.ts
|
||||
# personal agent config
|
||||
AGENTS.md
|
||||
.opencode
|
||||
|
||||
# local docker dev
|
||||
.docker-data/
|
||||
.docker-cache/
|
||||
@@ -1,67 +1,3 @@
|
||||
## [1.64.0](https://github.com/homarr-labs/homarr/compare/v1.63.0...v1.64.0) (2026-06-05)
|
||||
|
||||
### Features
|
||||
|
||||
* add comprehensive demo mode with mock integrations ([#5870](https://github.com/homarr-labs/homarr/issues/5870)) ([0a64679](https://github.com/homarr-labs/homarr/commit/0a6467979a5e3b3e3eae6252c31fca1d9af54dc1))
|
||||
* add PeaNUT integration and UPS monitoring widget ([#5862](https://github.com/homarr-labs/homarr/issues/5862)) ([0dc2558](https://github.com/homarr-labs/homarr/commit/0dc2558fafedcbba246c2e968d00cc541ab9f84e))
|
||||
* **docker:** add container logs modal and stats columns ([#4807](https://github.com/homarr-labs/homarr/issues/4807)) ([dbf021e](https://github.com/homarr-labs/homarr/commit/dbf021e04a785b44ad0e2ce45634de371d76de6d))
|
||||
* media request search as first-class spotlight mode ([#5823](https://github.com/homarr-labs/homarr/issues/5823)) ([3d3e305](https://github.com/homarr-labs/homarr/commit/3d3e3059a98b36b6e64e3aad90f715e334baff3e))
|
||||
* **onboarding:** enhance integration setup with Docker discovery and URL templating ([#5607](https://github.com/homarr-labs/homarr/issues/5607)) ([dd7a490](https://github.com/homarr-labs/homarr/commit/dd7a4908a36a71c1d0d1e41524eb8fd9bf53ca4d))
|
||||
* **onboarding:** guided management and board tours ([#5756](https://github.com/homarr-labs/homarr/issues/5756)) ([0b663a7](https://github.com/homarr-labs/homarr/commit/0b663a7fc6c017b9fbe78c019f06d46ee95f165f))
|
||||
* **widgets:** add in-place app editing in widget edit modal ([#5809](https://github.com/homarr-labs/homarr/issues/5809)) ([99346f2](https://github.com/homarr-labs/homarr/commit/99346f288bcfc701c41fecaab6f7bc0438d80563))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add bookmark widget option to toggle card borders ([#5854](https://github.com/homarr-labs/homarr/issues/5854)) ([41fe592](https://github.com/homarr-labs/homarr/commit/41fe5928f19207d02b04e64c2caac49d896f72ce))
|
||||
* **auth:** relax password complexity requirements to suggestions-only ([#5764](https://github.com/homarr-labs/homarr/issues/5764)) ([fd10788](https://github.com/homarr-labs/homarr/commit/fd107888385557ededcf51576c6320a1960690f2))
|
||||
* **db:** log when UNSAFE_ENABLE_MOCK_INTEGRATION is enabled during seeding ([efd37dc](https://github.com/homarr-labs/homarr/commit/efd37dca93be7f5f362331e9b5c2a95a87e24379))
|
||||
* disable onboarding tours when demo mode is enabled ([68f24e3](https://github.com/homarr-labs/homarr/commit/68f24e34d580e3a155c37ca37c3ffd61f296bd0c))
|
||||
* **docker:** restore container selection toolbar broken by Mantine v9 ([#5855](https://github.com/homarr-labs/homarr/issues/5855)) ([fe4598f](https://github.com/homarr-labs/homarr/commit/fe4598f7221a2861b8c5ae0cfd267e8c4fbf48df))
|
||||
* **immich:** respect app color scheme in server-stats widget ([#5859](https://github.com/homarr-labs/homarr/issues/5859)) ([2c6b7a8](https://github.com/homarr-labs/homarr/commit/2c6b7a818e9d32b414b024d711a3b64533e61cb9))
|
||||
* redirect to login after credentials logout instead of crashing on current page ([#5819](https://github.com/homarr-labs/homarr/issues/5819)) ([cc24f07](https://github.com/homarr-labs/homarr/commit/cc24f070db26337a4a2d87d68c7f142ed1118584))
|
||||
* sync docs sitemap and remove stale coolify ts-expect-error ([4f5f3e1](https://github.com/homarr-labs/homarr/commit/4f5f3e14a129a94295d4b8ffb7d3702fdb4f6a74))
|
||||
* **ui:** restore pre-v9 light variant colors ([#5856](https://github.com/homarr-labs/homarr/issues/5856)) ([a755692](https://github.com/homarr-labs/homarr/commit/a755692d81b80b5ff83051a3b7261c01ac10321b))
|
||||
|
||||
## [1.63.0](https://github.com/homarr-labs/homarr/compare/v1.62.0...v1.63.0) (2026-05-29)
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** support custom token endpoint auth methods ([#5806](https://github.com/homarr-labs/homarr/issues/5806)) ([be61602](https://github.com/homarr-labs/homarr/commit/be61602576a8bec4bb7e4928d36e0854e926264d))
|
||||
* auto-open widget settings on add with all integrations pre-selected ([#5767](https://github.com/homarr-labs/homarr/issues/5767)) ([f10bb3a](https://github.com/homarr-labs/homarr/commit/f10bb3ae23fefc9779332733706aa567cc40ba48))
|
||||
* **board:** persist category collapse state in localStorage for guests ([#5714](https://github.com/homarr-labs/homarr/issues/5714)) ([66212b4](https://github.com/homarr-labs/homarr/commit/66212b4e475738e61d2ea78f3a7e72d7a610bf12)), closes [#3355](https://github.com/homarr-labs/homarr/issues/3355)
|
||||
* **manage:** unified management page layout ([#5758](https://github.com/homarr-labs/homarr/issues/5758)) ([c252b4f](https://github.com/homarr-labs/homarr/commit/c252b4f9accc4523f222354c73f687714640701f))
|
||||
* **users:** rework general settings ([#4915](https://github.com/homarr-labs/homarr/issues/4915)) ([2991777](https://github.com/homarr-labs/homarr/commit/2991777b0a0ae72522d0769a86a91250d618dbe6))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* matine ui fixes for concistency ([#5733](https://github.com/homarr-labs/homarr/issues/5733)) ([4454e3f](https://github.com/homarr-labs/homarr/commit/4454e3fb7afa624ee2f1eaec015f2725e664ac46))
|
||||
* parse Speedtest Tracker created_at as local time ([#5751](https://github.com/homarr-labs/homarr/issues/5751)) ([b19549b](https://github.com/homarr-labs/homarr/commit/b19549b601636bcbc2fadaa270a77a9fdb48b77a))
|
||||
* prevent GitHub releases check from blocking app on API failure ([#5807](https://github.com/homarr-labs/homarr/issues/5807)) ([6cc2ac5](https://github.com/homarr-labs/homarr/commit/6cc2ac56a920563c47aaaab1b33b19a9b6d5ecee))
|
||||
* **ui:** use imported theme instead of undefined createTheme ([4a67bf0](https://github.com/homarr-labs/homarr/commit/4a67bf0ef30ff8d10b06d2d3719ccab792395669))
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **boards:** parallelize board page data fetching ([#5736](https://github.com/homarr-labs/homarr/issues/5736)) ([8f03a38](https://github.com/homarr-labs/homarr/commit/8f03a38de5b9b385da7ffcc9a963be00710acb5e))
|
||||
|
||||
## [1.62.0](https://github.com/homarr-labs/homarr/compare/v1.61.1...v1.62.0) (2026-05-22)
|
||||
|
||||
### Features
|
||||
|
||||
* **seed:** add demo mode with pre-seeded user ([#5739](https://github.com/homarr-labs/homarr/issues/5739)) ([dade477](https://github.com/homarr-labs/homarr/commit/dade477b3226765eb614e66588ebee84c45cb688))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps:** update dependency better-sqlite3 to ^12.10.0 ([#5727](https://github.com/homarr-labs/homarr/issues/5727)) ([044ad9e](https://github.com/homarr-labs/homarr/commit/044ad9edf9c1dc9b50b9591dea0b5f0cce4be01a))
|
||||
* **docker:** improve icon matching ([#5693](https://github.com/homarr-labs/homarr/issues/5693)) ([7cd30b6](https://github.com/homarr-labs/homarr/commit/7cd30b640cbf47e4678abda18f83c0924f039b1b))
|
||||
* exclude .env files from Docker build context ([#5738](https://github.com/homarr-labs/homarr/issues/5738)) ([fa73763](https://github.com/homarr-labs/homarr/commit/fa737638a812372d904207fe7af5bd7c6f53d159))
|
||||
* **tracearr:** support absolute image urls ([#5654](https://github.com/homarr-labs/homarr/issues/5654)) ([7a12721](https://github.com/homarr-labs/homarr/commit/7a1272146c597c1f9df89d74b8389f70e7cdd61a))
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* (next branch) reduce idle memory on startup ([#5637](https://github.com/homarr-labs/homarr/issues/5637)) ([5ee84f8](https://github.com/homarr-labs/homarr/commit/5ee84f8cf464c308729281cb4db572950ba85a82))
|
||||
* **boards:** remove loading overlay and show board immediately ([#5735](https://github.com/homarr-labs/homarr/issues/5735)) ([df4b57b](https://github.com/homarr-labs/homarr/commit/df4b57bd414d4402522f9210b1470d0561c37274))
|
||||
* **memory:** combine 3 node processes into one ([#5600](https://github.com/homarr-labs/homarr/issues/5600)) ([a1aa550](https://github.com/homarr-labs/homarr/commit/a1aa5505a365688b456090a882770e561479833e))
|
||||
|
||||
## [1.61.1](https://github.com/homarr-labs/homarr/compare/v1.61.0...v1.61.1) (2026-05-18)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -18,11 +18,9 @@
|
||||
"@dnd-kit/modifiers": "catalog:",
|
||||
"@dnd-kit/sortable": "catalog:",
|
||||
"@dnd-kit/utilities": "catalog:",
|
||||
"@gfazioli/mantine-onboarding-tour": "catalog:",
|
||||
"@homarr/analytics": "workspace:^0.1.0",
|
||||
"@homarr/api": "workspace:^0.1.0",
|
||||
"@homarr/auth": "workspace:^0.1.0",
|
||||
"@homarr/board-portability": "workspace:^0.1.0",
|
||||
"@homarr/boards": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/core": "workspace:^0.1.0",
|
||||
@@ -65,6 +63,7 @@
|
||||
"@trpc/next": "catalog:",
|
||||
"@trpc/react-query": "catalog:",
|
||||
"@trpc/server": "catalog:",
|
||||
"@xterm/addon-canvas": "catalog:",
|
||||
"@xterm/addon-fit": "catalog:",
|
||||
"@xterm/xterm": "catalog:",
|
||||
"babel-plugin-react-compiler": "catalog:",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
|
||||
import { DirectionProvider, MantineProvider, v8CssVariablesResolver } from "@mantine/core";
|
||||
import { DirectionProvider, MantineProvider } from "@mantine/core";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -18,12 +18,7 @@ export const CustomMantineProvider = ({
|
||||
const manager = useColorSchemeManager();
|
||||
return (
|
||||
<DirectionProvider>
|
||||
<MantineProvider
|
||||
defaultColorScheme={defaultColorScheme}
|
||||
colorSchemeManager={manager}
|
||||
theme={theme}
|
||||
cssVariablesResolver={v8CssVariablesResolver}
|
||||
>
|
||||
<MantineProvider defaultColorScheme={defaultColorScheme} colorSchemeManager={manager} theme={theme}>
|
||||
{children}
|
||||
</MantineProvider>
|
||||
</DirectionProvider>
|
||||
|
||||
@@ -17,13 +17,10 @@ import {
|
||||
import superjson from "superjson";
|
||||
import type { SuperJSONResult } from "superjson";
|
||||
|
||||
import { TRPCClientError } from "@trpc/client";
|
||||
|
||||
import type { AppRouter } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { showWarningNotification } from "@homarr/notifications";
|
||||
|
||||
const getWebSocketProtocol = () => {
|
||||
// window is not defined on server side
|
||||
@@ -59,20 +56,6 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
||||
queries: {
|
||||
staleTime: 5 * 1000,
|
||||
},
|
||||
mutations: {
|
||||
onError(error) {
|
||||
if (
|
||||
error instanceof TRPCClientError &&
|
||||
error.data?.code === "FORBIDDEN" &&
|
||||
error.message === "Mutations are disabled in demo mode"
|
||||
) {
|
||||
showWarningNotification({
|
||||
title: "Demo mode",
|
||||
message: "This action is disabled in demo mode.",
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { Alert, Card, Center, Code, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconLogin } from "@tabler/icons-react";
|
||||
import { Card, Center, Stack, Text, Title } from "@mantine/core";
|
||||
|
||||
import { env } from "@homarr/auth/env";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
|
||||
|
||||
import { env as appEnv } from "~/env";
|
||||
|
||||
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
|
||||
import { LoginForm } from "./_login-form";
|
||||
|
||||
@@ -40,13 +37,6 @@ export default async function Login(props: LoginProps) {
|
||||
{t("subtitle")}
|
||||
</Text>
|
||||
</Stack>
|
||||
{appEnv.DEMO_MODE && (
|
||||
<Alert icon={<IconLogin size={18} />} color="blue" variant="light" w={64 * 6} maw="90vw">
|
||||
<Text size="sm" fw={500}>
|
||||
Demo mode is enabled. Sign in with username <Code>demo</Code> and password <Code>demo</Code>
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
<Card w={64 * 6} maw="90vw">
|
||||
<LoginForm
|
||||
providers={env.AUTH_PROVIDERS}
|
||||
|
||||
@@ -38,7 +38,6 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
layout: createBoardLayout({
|
||||
headerActions: <BoardContentHeaderActions />,
|
||||
getInitialBoardAsync: getInitialBoard,
|
||||
withTour: true,
|
||||
}),
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
page: async ({ params }: { params: Promise<TParams> }) => {
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
||||
import { Box, Group, Menu, ScrollArea } from "@mantine/core";
|
||||
import { Group, Menu, ScrollArea } from "@mantine/core";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
import {
|
||||
IconBox,
|
||||
@@ -12,7 +11,6 @@ import {
|
||||
IconLayoutBoard,
|
||||
IconPencil,
|
||||
IconPencilOff,
|
||||
IconPlug,
|
||||
IconPlus,
|
||||
IconReplace,
|
||||
IconResize,
|
||||
@@ -38,16 +36,14 @@ import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||
import { useCategoryActions } from "~/components/board/sections/category/category-actions";
|
||||
import { CategoryEditModal } from "~/components/board/sections/category/category-edit-modal";
|
||||
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
|
||||
export const BoardContentHeaderActions = () => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
const { data: demoMode, isLoading } = clientApi.info.isDemoMode.useQuery();
|
||||
|
||||
if (!hasChangeAccess || isLoading) {
|
||||
if (!hasChangeAccess) {
|
||||
return <SelectBoardsMenu />;
|
||||
}
|
||||
|
||||
@@ -55,15 +51,11 @@ export const BoardContentHeaderActions = () => {
|
||||
<>
|
||||
{isEditMode && <AddMenu />}
|
||||
|
||||
<EditModeMenu demoMode={demoMode ?? false} />
|
||||
<EditModeMenu />
|
||||
|
||||
{!demoMode && (
|
||||
<OnboardingTour.Target id="board-settings">
|
||||
<HeaderButton href={`/boards/${board.name}/settings`}>
|
||||
<IconSettings stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</OnboardingTour.Target>
|
||||
)}
|
||||
<HeaderButton href={`/boards/${board.name}/settings`}>
|
||||
<IconSettings stroke={1.5} />
|
||||
</HeaderButton>
|
||||
|
||||
<SelectBoardsMenu />
|
||||
</>
|
||||
@@ -75,7 +67,6 @@ const AddMenu = () => {
|
||||
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
|
||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
||||
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
|
||||
const { addCategoryToEnd } = useCategoryActions();
|
||||
const { addDynamicSection } = useDynamicSectionActions();
|
||||
const { createItem } = useItemActions();
|
||||
@@ -117,10 +108,6 @@ const AddMenu = () => {
|
||||
});
|
||||
}, [openAppSelectModal, createItem]);
|
||||
|
||||
const handleAddIntegration = useCallback(() => {
|
||||
openIntegrationSelectModal({});
|
||||
}, [openIntegrationSelectModal]);
|
||||
|
||||
return (
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
@@ -140,10 +127,6 @@ const AddMenu = () => {
|
||||
{t("app.action.add")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
|
||||
{t("integration.action.create")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item leftSection={<IconBoxAlignTop size={20} />} onClick={handleAddCategory}>
|
||||
@@ -158,7 +141,7 @@ const AddMenu = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => {
|
||||
const EditModeMenu = () => {
|
||||
const [isEditMode, { open, close }] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
const utils = clientApi.useUtils();
|
||||
@@ -181,28 +164,18 @@ const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => {
|
||||
},
|
||||
});
|
||||
|
||||
const discardDemoChanges = useCallback(() => {
|
||||
void utils.board.getBoardByName.invalidate({ name: board.name });
|
||||
close();
|
||||
}, [utils, board.name, close]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (isEditMode) {
|
||||
if (demoMode) return discardDemoChanges();
|
||||
return saveBoard(board);
|
||||
}
|
||||
if (isEditMode) return saveBoard(board);
|
||||
open();
|
||||
}, [board, isEditMode, demoMode, saveBoard, open, discardDemoChanges]);
|
||||
}, [board, isEditMode, saveBoard, open]);
|
||||
|
||||
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
|
||||
usePreventLeaveWithDirty(isEditMode);
|
||||
|
||||
return (
|
||||
<OnboardingTour.Target id="board-edit-mode">
|
||||
<HeaderButton onClick={toggle} loading={isPending}>
|
||||
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
|
||||
</HeaderButton>
|
||||
</OnboardingTour.Target>
|
||||
<HeaderButton onClick={toggle} loading={isPending}>
|
||||
{isEditMode ? <IconPencilOff stroke={1.5} /> : <IconPencil stroke={1.5} />}
|
||||
</HeaderButton>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -210,31 +183,27 @@ const SelectBoardsMenu = () => {
|
||||
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
|
||||
|
||||
return (
|
||||
<OnboardingTour.Target id="board-switcher">
|
||||
<Box>
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4}>
|
||||
<IconReplace stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
|
||||
<ScrollArea.Autosize mah={300}>
|
||||
{boards.map((board) => (
|
||||
<Menu.Item
|
||||
key={board.id}
|
||||
component={Link}
|
||||
href={`/boards/${board.name}`}
|
||||
leftSection={<IconLayoutBoard size={20} />}
|
||||
>
|
||||
{board.name}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</ScrollArea.Autosize>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Box>
|
||||
</OnboardingTour.Target>
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4}>
|
||||
<IconReplace stroke={1.5} />
|
||||
</HeaderButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
|
||||
<ScrollArea.Autosize mah={300}>
|
||||
{boards.map((board) => (
|
||||
<Menu.Item
|
||||
key={board.id}
|
||||
component={Link}
|
||||
href={`/boards/${board.name}`}
|
||||
leftSection={<IconLayoutBoard size={20} />}
|
||||
>
|
||||
{board.name}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</ScrollArea.Autosize>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -275,7 +244,7 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
|
||||
};
|
||||
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (env.NODE_ENV === "development") return;
|
||||
if (env.NODE_ENV === "development") return; // Allow to reload in development
|
||||
|
||||
event.preventDefault();
|
||||
event.returnValue = true;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
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 type { ColorScheme } from "@homarr/definitions";
|
||||
@@ -33,12 +33,7 @@ export const BoardMantineProvider = ({
|
||||
});
|
||||
|
||||
return (
|
||||
<MantineProvider
|
||||
defaultColorScheme={defaultColorScheme}
|
||||
theme={theme}
|
||||
colorSchemeManager={colorSchemeManager}
|
||||
cssVariablesResolver={v8CssVariablesResolver}
|
||||
>
|
||||
<MantineProvider defaultColorScheme={defaultColorScheme} theme={theme} colorSchemeManager={colorSchemeManager}>
|
||||
{children}
|
||||
</MantineProvider>
|
||||
);
|
||||
|
||||
@@ -11,7 +11,6 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { MainHeader } from "~/components/layout/header";
|
||||
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
import { BoardTourProvider } from "~/components/onboarding/board-tour";
|
||||
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||
import type { Board } from "./_types";
|
||||
import type { Params } from "./(content)/_creator";
|
||||
@@ -21,21 +20,14 @@ import { BoardMantineProvider } from "./(content)/_theme";
|
||||
|
||||
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> {
|
||||
headerActions: JSX.Element;
|
||||
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
||||
withTour?: boolean;
|
||||
}
|
||||
|
||||
export const createBoardLayout = <TParams extends Params>({
|
||||
headerActions,
|
||||
getInitialBoardAsync: getInitialBoard,
|
||||
withTour = false,
|
||||
}: CreateBoardLayoutProps<TParams>) => {
|
||||
const Layout = async ({
|
||||
params,
|
||||
@@ -69,16 +61,14 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
<EditModeProvider>
|
||||
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
||||
<CustomCss />
|
||||
<BoardTourWrapper hasSession={withTour && !!session}>
|
||||
<ClientShell hasNavigation={false}>
|
||||
<MainHeader
|
||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||
actions={headerActions}
|
||||
hasNavigation={false}
|
||||
/>
|
||||
<AppShellMain>{children}</AppShellMain>
|
||||
</ClientShell>
|
||||
</BoardTourWrapper>
|
||||
<ClientShell hasNavigation={false}>
|
||||
<MainHeader
|
||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||
actions={headerActions}
|
||||
hasNavigation={false}
|
||||
/>
|
||||
<AppShellMain>{children}</AppShellMain>
|
||||
</ClientShell>
|
||||
</BoardMantineProvider>
|
||||
</EditModeProvider>
|
||||
</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";
|
||||
@@ -6,14 +6,11 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { clearInitImportMode } from "./import/use-init-import-mode";
|
||||
|
||||
export const BackToStart = () => {
|
||||
const t = useI18n();
|
||||
const { mutateAsync, isPending } = clientApi.onboard.previousStep.useMutation();
|
||||
|
||||
const handleBackToStartAsync = async () => {
|
||||
clearInitImportMode();
|
||||
await mutateAsync();
|
||||
await revalidatePathActionAsync("/init");
|
||||
};
|
||||
|
||||
@@ -1,27 +1,39 @@
|
||||
import type { MantineColor } from "@mantine/core";
|
||||
import { Button, Stack, Text } from "@mantine/core";
|
||||
import { IconBook2, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react";
|
||||
import { Button, Card, Stack, Text } from "@mantine/core";
|
||||
import { IconBook2, IconCategoryPlus, IconLayoutDashboard, IconMailForward } from "@tabler/icons-react";
|
||||
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { getMantineColor } from "@homarr/common";
|
||||
import { db } from "@homarr/db";
|
||||
import { createDocumentationLink } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { Link } from "@homarr/ui";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import { InitStepCard } from "../../_components/init-step-card";
|
||||
|
||||
export const InitFinish = async () => {
|
||||
const firstBoard = await db.query.boards.findFirst({ columns: { name: true } });
|
||||
const tFinish = await getScopedI18n("init.step.finish");
|
||||
|
||||
return (
|
||||
<InitStepCard>
|
||||
<Card w={64 * 6} maw="90vw">
|
||||
<Stack>
|
||||
<Text>{tFinish("description")}</Text>
|
||||
|
||||
<InternalLinkButton href="/auth/login?callbackUrl=/" iconProps={{ icon: IconLayoutDashboard, color: "blue" }}>
|
||||
{tFinish("action.goToBoard", { name: "dashboard" })}
|
||||
</InternalLinkButton>
|
||||
{firstBoard ? (
|
||||
<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") && (
|
||||
<InternalLinkButton
|
||||
@@ -39,7 +51,7 @@ export const InitFinish = async () => {
|
||||
{tFinish("action.docs")}
|
||||
</ExternalLinkButton>
|
||||
</Stack>
|
||||
</InitStepCard>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Stack, TextInput } from "@mantine/core";
|
||||
import { Button, Card, Stack, TextInput } from "@mantine/core";
|
||||
import { IconArrowRight } from "@tabler/icons-react";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
@@ -10,8 +10,6 @@ import { useZodForm } from "@homarr/form";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { groupCreateSchema } from "@homarr/validation/group";
|
||||
|
||||
import { InitStepCard } from "../../_components/init-step-card";
|
||||
|
||||
export const InitGroup = () => {
|
||||
const t = useI18n();
|
||||
const { mutateAsync } = clientApi.group.createInitialExternalGroup.useMutation({
|
||||
@@ -36,7 +34,7 @@ export const InitGroup = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<InitStepCard>
|
||||
<Card w={64 * 6} maw="90vw">
|
||||
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
@@ -50,6 +48,6 @@ export const InitGroup = () => {
|
||||
</Button>
|
||||
</Stack>
|
||||
</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 { IconPencil } from "@tabler/icons-react";
|
||||
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { InitStepCard } from "../../_components/init-step-card";
|
||||
|
||||
interface FileInfoCardProps {
|
||||
file: FileWithPath;
|
||||
onRemove: () => void;
|
||||
@@ -15,7 +13,7 @@ interface FileInfoCardProps {
|
||||
export const FileInfoCard = ({ file, onRemove }: FileInfoCardProps) => {
|
||||
const tFileInfo = useScopedI18n("init.step.import.fileInfo");
|
||||
return (
|
||||
<InitStepCard withBorder={false}>
|
||||
<Card w={64 * 12 + 8} maw="90vw" withBorder={false}>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group>
|
||||
<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} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</InitStepCard>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,116 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button, Stack } from "@mantine/core";
|
||||
import type { FileWithPath } from "@mantine/dropzone";
|
||||
import { IconUpload } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { ConfigImportPreviewPanel } from "~/components/config-import/config-import-preview-panel";
|
||||
|
||||
import { FileInfoCard } from "./file-info-card";
|
||||
|
||||
export const InitConfigImport = () => {
|
||||
const t = useI18n() as unknown as (key: string, values?: Record<string, unknown>) => string;
|
||||
const [file, setFile] = useState<FileWithPath | null>(null);
|
||||
const [fileContent, setFileContent] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const hasOpenedRef = useRef(false);
|
||||
|
||||
const {
|
||||
mutate: runPreview,
|
||||
data: preview,
|
||||
isPending: previewLoading,
|
||||
reset: resetPreview,
|
||||
} = clientApi.import.previewInitialConfigImport.useMutation();
|
||||
|
||||
const { mutateAsync, isPending } = clientApi.import.importInitialConfigImport.useMutation({
|
||||
async onSuccess() {
|
||||
await revalidatePathActionAsync("/init");
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (hasOpenedRef.current) return;
|
||||
hasOpenedRef.current = true;
|
||||
setTimeout(() => inputRef.current?.click(), 100);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileContent) {
|
||||
resetPreview();
|
||||
return;
|
||||
}
|
||||
runPreview({ content: fileContent });
|
||||
}, [fileContent, runPreview, resetPreview]);
|
||||
|
||||
const importAllowed = preview?.compatibility.status === "compatible";
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file || !importAllowed) return;
|
||||
|
||||
await mutateAsync(
|
||||
{ content: fileContent },
|
||||
{
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("management.page.importExport.importConfig.notification.error.title"),
|
||||
message: t("management.page.importExport.importConfig.notification.error.message"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = event.target.files?.[0];
|
||||
if (!selectedFile) return;
|
||||
|
||||
void selectedFile.text().then((content) => {
|
||||
setFileContent(content);
|
||||
setFile(selectedFile as FileWithPath);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack w="100%" mb="sm">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={handleFileChange}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
|
||||
{file && (
|
||||
<>
|
||||
<FileInfoCard
|
||||
file={file}
|
||||
onRemove={() => {
|
||||
setFile(null);
|
||||
setFileContent("");
|
||||
}}
|
||||
/>
|
||||
<ConfigImportPreviewPanel preview={preview} previewLoading={previewLoading} t={t} />
|
||||
<Button
|
||||
loading={isPending}
|
||||
disabled={!importAllowed || previewLoading}
|
||||
onClick={() => void handleImport()}
|
||||
>
|
||||
{t("init.step.import.configImport.action")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!file && (
|
||||
<Button variant="light" leftSection={<IconUpload size={16} />} onClick={() => inputRef.current?.click()}>
|
||||
{t("init.step.import.configImport.selectFile")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -1,30 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { startTransition, useState } from "react";
|
||||
import { Stack } from "@mantine/core";
|
||||
import { Card, Stack } from "@mantine/core";
|
||||
import type { FileWithPath } from "@mantine/dropzone";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { InitialOldmarrImport } from "@homarr/old-import/components";
|
||||
|
||||
import { InitStepCard } from "../../_components/init-step-card";
|
||||
import { FileInfoCard } from "./file-info-card";
|
||||
import { ImportDropZone } from "./import-dropzone";
|
||||
import { InitConfigImport } from "./init-config-import";
|
||||
import { useInitImportMode } from "./use-init-import-mode";
|
||||
|
||||
export const InitImport = () => {
|
||||
const importMode = useInitImportMode();
|
||||
|
||||
if (importMode === "config") {
|
||||
return <InitConfigImport />;
|
||||
}
|
||||
|
||||
return <LegacyInitImport />;
|
||||
};
|
||||
|
||||
const LegacyInitImport = () => {
|
||||
const [file, setFile] = useState<FileWithPath | null>(null);
|
||||
const { isPending, mutate } = clientApi.import.analyseInitialOldmarrImport.useMutation();
|
||||
const [analyseResult, setAnalyseResult] = useState<RouterOutputs["import"]["analyseInitialOldmarrImport"] | null>(
|
||||
@@ -33,7 +20,7 @@ const LegacyInitImport = () => {
|
||||
|
||||
if (!file) {
|
||||
return (
|
||||
<InitStepCard>
|
||||
<Card w={64 * 12 + 8} maw="90vw">
|
||||
<ImportDropZone
|
||||
loading={isPending}
|
||||
updateFile={(file) => {
|
||||
@@ -53,12 +40,12 @@ const LegacyInitImport = () => {
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</InitStepCard>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack w="100%" mb="sm">
|
||||
<Stack mb="sm">
|
||||
<FileInfoCard file={file} onRemove={() => setFile(null)} />
|
||||
{analyseResult !== null && <InitialOldmarrImport file={file} analyseResult={analyseResult} />}
|
||||
</Stack>
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export type InitImportMode = "legacy" | "config";
|
||||
|
||||
const storageKey = "homarr-init-import-mode";
|
||||
|
||||
const readMode = (): InitImportMode => {
|
||||
const stored = sessionStorage.getItem(storageKey);
|
||||
return stored === "config" ? "config" : "legacy";
|
||||
};
|
||||
|
||||
const subscribe = (onStoreChange: () => void) => {
|
||||
window.addEventListener("storage", onStoreChange);
|
||||
return () => window.removeEventListener("storage", onStoreChange);
|
||||
};
|
||||
|
||||
export const setInitImportMode = (mode: InitImportMode) => {
|
||||
sessionStorage.setItem(storageKey, mode);
|
||||
window.dispatchEvent(new Event("storage"));
|
||||
};
|
||||
|
||||
export const clearInitImportMode = () => {
|
||||
sessionStorage.removeItem(storageKey);
|
||||
window.dispatchEvent(new Event("storage"));
|
||||
};
|
||||
|
||||
export const useInitImportMode = () =>
|
||||
useSyncExternalStore(subscribe, readMode, () => "legacy" as InitImportMode);
|
||||
@@ -1,209 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Card,
|
||||
Collapse,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { IconBrandDocker, IconCheck, IconChevronDown } from "@tabler/icons-react";
|
||||
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { IntegrationAvatar } from "@homarr/ui";
|
||||
|
||||
export interface DiscoveredIntegration {
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
kind: IntegrationKind;
|
||||
suggestedUrl: string;
|
||||
publishedPort: number | null;
|
||||
iconUrl: string | null;
|
||||
}
|
||||
|
||||
export interface DiscoveredApp {
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
suggestedUrl: string;
|
||||
publishedPort: number | null;
|
||||
iconUrl: string | null;
|
||||
}
|
||||
|
||||
interface DockerDiscoveryIndicatorProps {
|
||||
integrations: DiscoveredIntegration[];
|
||||
apps: DiscoveredApp[];
|
||||
selectedAppIds: Set<string>;
|
||||
onToggleApp: (containerId: string) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const APP_CARD_HEIGHT = 80;
|
||||
|
||||
export const DockerDiscoveryIndicator = ({
|
||||
integrations,
|
||||
apps,
|
||||
selectedAppIds,
|
||||
onToggleApp,
|
||||
isLoading,
|
||||
}: DockerDiscoveryIndicatorProps) => {
|
||||
const hasApps = apps.length > 0;
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const expandedOnce = useRef(false);
|
||||
useEffect(() => {
|
||||
if ((hasApps || integrations.length > 0) && !expandedOnce.current) {
|
||||
expandedOnce.current = true;
|
||||
setExpanded(true);
|
||||
}
|
||||
}, [hasApps, integrations.length]);
|
||||
|
||||
const t = useScopedI18n("init.step.integrations.docker");
|
||||
const totalCount = integrations.length + apps.length;
|
||||
|
||||
if (!isLoading && totalCount === 0) return null;
|
||||
|
||||
const selectedAppCount = apps.filter((app) => selectedAppIds.has(app.containerId)).length;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Paper
|
||||
withBorder
|
||||
p="sm"
|
||||
radius="md"
|
||||
style={{
|
||||
borderColor: "var(--mantine-color-blue-4)",
|
||||
background: "var(--mantine-color-blue-light)",
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color="blue" size="lg" radius="md">
|
||||
<IconBrandDocker size={20} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={0} style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={600}>
|
||||
{t("scanning")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("scanningDescription")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Loader size="sm" color="blue" type="dots" />
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper
|
||||
withBorder
|
||||
p="sm"
|
||||
radius="md"
|
||||
style={{
|
||||
borderColor: "var(--mantine-color-blue-4)",
|
||||
background: "var(--mantine-color-blue-light)",
|
||||
}}
|
||||
>
|
||||
<UnstyledButton onClick={() => setExpanded((prev) => !prev)} w="100%">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color="blue" size="lg" radius="md">
|
||||
<IconBrandDocker size={20} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw={600}>
|
||||
{t("detected", { count: String(totalCount) })}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("detectedBreakdown", {
|
||||
integrations: String(integrations.length),
|
||||
apps: String(apps.length),
|
||||
})}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<IconChevronDown
|
||||
size={16}
|
||||
color="var(--mantine-color-dimmed)"
|
||||
style={{
|
||||
transform: expanded ? "rotate(180deg)" : undefined,
|
||||
transition: "transform 200ms ease",
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
|
||||
<Collapse expanded={expanded}>
|
||||
<Stack gap="xs" mt="sm">
|
||||
{integrations.length > 0 && (
|
||||
<Stack gap={4}>
|
||||
{integrations.map((integration) => (
|
||||
<Group key={integration.containerId} gap="xs" wrap="nowrap" py={2}>
|
||||
<IntegrationAvatar kind={integration.kind} size="xs" />
|
||||
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
|
||||
{integration.containerName}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color="blue">
|
||||
{getIntegrationName(integration.kind)}
|
||||
</Badge>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{hasApps && (
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={500}>
|
||||
{t("appSectionLabel", { count: String(selectedAppCount) })}
|
||||
</Text>
|
||||
<ScrollArea.Autosize mah="30vh">
|
||||
<SimpleGrid cols={{ base: 3, xs: 4, sm: 5 }} spacing="xs">
|
||||
{apps.map((app) => {
|
||||
const isSelected = selectedAppIds.has(app.containerId);
|
||||
return (
|
||||
<Card
|
||||
key={app.containerId}
|
||||
h={APP_CARD_HEIGHT}
|
||||
p="xs"
|
||||
withBorder
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
borderColor: isSelected ? "var(--mantine-color-blue-6)" : undefined,
|
||||
borderWidth: isSelected ? 2 : undefined,
|
||||
opacity: isSelected ? 1 : 0.6,
|
||||
}}
|
||||
onClick={() => onToggleApp(app.containerId)}
|
||||
>
|
||||
<Stack justify="space-between" h="100%" gap={4} align="center">
|
||||
<Group justify="space-between" w="100%" wrap="nowrap">
|
||||
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
|
||||
{app.containerName}
|
||||
</Text>
|
||||
{isSelected && <IconCheck size={14} color="var(--mantine-color-blue-6)" />}
|
||||
</Group>
|
||||
<Avatar size="sm" radius="sm" src={app.iconUrl} styles={{ image: { objectFit: "contain" } }}>
|
||||
{app.containerName.at(0)?.toUpperCase()}
|
||||
</Avatar>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -1,483 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Collapse,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Progress,
|
||||
SegmentedControl,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowRight,
|
||||
IconBrandDocker,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconSettings,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { IntegrationKind, UrlTemplateMode } from "@homarr/definitions";
|
||||
import { buildAppUrl, buildIntegrationUrl, getIntegrationName } from "@homarr/definitions";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { IntegrationAvatar } from "@homarr/ui";
|
||||
|
||||
import { NewIntegrationForm } from "~/app/[locale]/manage/integrations/new/_integration-new-form";
|
||||
import { IntegrationMultiSelectGrid } from "~/components/integration/integration-multi-select-grid";
|
||||
import { InitStepCard } from "../../_components/init-step-card";
|
||||
|
||||
type Phase = "select" | "configure" | "done";
|
||||
|
||||
interface IntegrationDraft {
|
||||
kind: IntegrationKind;
|
||||
initialUrl: string;
|
||||
initialName?: string;
|
||||
dockerPort?: number | null;
|
||||
source: "manual" | "docker";
|
||||
}
|
||||
|
||||
export const InitIntegrations = () => {
|
||||
const [phase, setPhase] = useState<Phase>("select");
|
||||
const [drafts, setDrafts] = useState<Map<IntegrationKind, IntegrationDraft>>(new Map());
|
||||
const [selectedKinds, setSelectedKinds] = useState<IntegrationKind[]>([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [baseHost, setBaseHost] = useState("");
|
||||
const [urlMode, setUrlMode] = useState<UrlTemplateMode>("subdomain");
|
||||
const t = useScopedI18n("init.step.integrations");
|
||||
const router = useRouter();
|
||||
|
||||
const { mutateAsync: setupIntegrations } = clientApi.onboard.setupIntegrations.useMutation();
|
||||
const { mutateAsync: createApps } = clientApi.onboard.createAppsFromDiscovery.useMutation();
|
||||
|
||||
const { data: dockerData, isPending: isDockerLoading } = clientApi.onboard.discoverDockerServices.useQuery(
|
||||
undefined,
|
||||
{
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
const dockerIntegrations = dockerData?.status === "success" ? dockerData.integrations : [];
|
||||
const dockerApps = dockerData?.status === "success" ? dockerData.apps : [];
|
||||
const totalDetected = dockerIntegrations.length + dockerApps.length;
|
||||
|
||||
const detectedKinds = useMemo(() => new Set(dockerIntegrations.map((i) => i.kind)), [dockerIntegrations]);
|
||||
|
||||
const [selectedAppIds, setSelectedAppIds] = useState<Set<string>>(new Set());
|
||||
const [appsExpanded, setAppsExpanded] = useState(false);
|
||||
|
||||
const dockerApplied = useRef(false);
|
||||
useEffect(() => {
|
||||
if (dockerData?.status !== "success" || dockerApplied.current) return;
|
||||
dockerApplied.current = true;
|
||||
|
||||
const newDrafts = new Map(drafts);
|
||||
const newKinds = new Set(selectedKinds);
|
||||
|
||||
for (const item of dockerData.integrations) {
|
||||
if (!newDrafts.has(item.kind)) {
|
||||
newDrafts.set(item.kind, {
|
||||
kind: item.kind,
|
||||
initialUrl: item.suggestedUrl,
|
||||
initialName: item.containerName,
|
||||
dockerPort: item.publishedPort,
|
||||
source: "docker",
|
||||
});
|
||||
}
|
||||
newKinds.add(item.kind);
|
||||
}
|
||||
|
||||
setDrafts(newDrafts);
|
||||
setSelectedKinds(Array.from(newKinds));
|
||||
setSelectedAppIds(new Set(dockerData.apps.map((app) => app.containerId)));
|
||||
if (dockerData.apps.length > 0) {
|
||||
setAppsExpanded(true);
|
||||
}
|
||||
}, [dockerData]);
|
||||
|
||||
const handleSelectionChange = useCallback((kinds: IntegrationKind[]) => {
|
||||
setSelectedKinds(kinds);
|
||||
}, []);
|
||||
|
||||
const handleToggleApp = useCallback((containerId: string) => {
|
||||
setSelectedAppIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(containerId)) {
|
||||
next.delete(containerId);
|
||||
} else {
|
||||
next.add(containerId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleContinueToConfig = () => {
|
||||
const newDrafts = new Map(drafts);
|
||||
for (const kind of selectedKinds) {
|
||||
const existing = newDrafts.get(kind);
|
||||
if (baseHost) {
|
||||
newDrafts.set(kind, {
|
||||
kind,
|
||||
initialUrl: buildIntegrationUrl(kind, baseHost, urlMode, existing?.dockerPort ?? undefined),
|
||||
initialName: existing?.initialName,
|
||||
dockerPort: existing?.dockerPort,
|
||||
source: existing?.source ?? "manual",
|
||||
});
|
||||
} else if (!existing) {
|
||||
newDrafts.set(kind, { kind, initialUrl: "", source: "manual" });
|
||||
}
|
||||
}
|
||||
setDrafts(newDrafts);
|
||||
setCurrentIndex(0);
|
||||
setPhase("configure");
|
||||
};
|
||||
|
||||
const finishSetupAsync = async () => {
|
||||
setPhase("done");
|
||||
try {
|
||||
const appsToCreate = dockerApps.filter((app) => selectedAppIds.has(app.containerId));
|
||||
if (appsToCreate.length > 0) {
|
||||
await createApps(
|
||||
appsToCreate.map((app) => {
|
||||
const href = baseHost
|
||||
? buildAppUrl(app.containerName, baseHost, urlMode, app.publishedPort ?? undefined)
|
||||
: app.suggestedUrl;
|
||||
return {
|
||||
name: app.containerName,
|
||||
href: href || null,
|
||||
iconUrl: app.iconUrl,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
await setupIntegrations();
|
||||
router.refresh();
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("configure.error.title"),
|
||||
message: t("configure.error.message"),
|
||||
});
|
||||
setPhase("select");
|
||||
}
|
||||
};
|
||||
|
||||
const advanceOrFinish = () => {
|
||||
if (currentIndex + 1 >= selectedKinds.length) {
|
||||
void finishSetupAsync();
|
||||
} else {
|
||||
setCurrentIndex((i) => i + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const backToSelect = () => {
|
||||
setPhase("select");
|
||||
setCurrentIndex(0);
|
||||
};
|
||||
|
||||
if (phase === "done") {
|
||||
return (
|
||||
<InitStepCard>
|
||||
<Center py="xl">
|
||||
<Stack align="center" gap="md">
|
||||
<Loader size="lg" />
|
||||
<Text>{t("configure.finishing")}</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</InitStepCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (phase === "configure") {
|
||||
return (
|
||||
<ConfigurePhase
|
||||
selectedKinds={selectedKinds}
|
||||
currentIndex={currentIndex}
|
||||
drafts={drafts}
|
||||
baseHost={baseHost}
|
||||
setBaseHost={setBaseHost}
|
||||
urlMode={urlMode}
|
||||
setUrlMode={setUrlMode}
|
||||
onSuccess={advanceOrFinish}
|
||||
onCancel={backToSelect}
|
||||
onSkip={advanceOrFinish}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedAppCount = dockerApps.filter((app) => selectedAppIds.has(app.containerId)).length;
|
||||
|
||||
return (
|
||||
<InitStepCard>
|
||||
<Stack>
|
||||
<Text>{t("description")}</Text>
|
||||
|
||||
<Alert variant="outline" color="orange" icon={<IconAlertTriangle />} title={t("urlTemplate.label")}>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("urlTemplate.warning")}
|
||||
</Text>
|
||||
<Group gap="sm" grow>
|
||||
<TextInput
|
||||
placeholder={t("urlTemplate.placeholder")}
|
||||
value={baseHost}
|
||||
onChange={(e) => setBaseHost(e.currentTarget.value)}
|
||||
data-autofocus
|
||||
/>
|
||||
<SegmentedControl
|
||||
value={urlMode}
|
||||
onChange={(value) => setUrlMode(value as UrlTemplateMode)}
|
||||
data={[
|
||||
{ label: t("urlTemplate.mode.subdomain"), value: "subdomain" },
|
||||
{ label: t("urlTemplate.mode.hostPort"), value: "hostPort" },
|
||||
]}
|
||||
style={{ flex: "0 0 auto" }}
|
||||
/>
|
||||
</Group>
|
||||
{baseHost && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("urlTemplate.preview", { example: buildIntegrationUrl("sonarr", baseHost, urlMode) })}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Alert>
|
||||
|
||||
{isDockerLoading && (
|
||||
<Paper withBorder p="sm" radius="md">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color="blue" size="lg" radius="md">
|
||||
<IconBrandDocker size={20} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("docker.scanning")}
|
||||
</Text>
|
||||
<Loader size="sm" color="blue" type="dots" />
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{!isDockerLoading && totalDetected > 0 && (
|
||||
<Paper withBorder p="sm" radius="md" style={{ borderColor: "var(--mantine-color-teal-4)" }}>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color="teal" size="lg" radius="md">
|
||||
<IconBrandDocker size={20} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm">
|
||||
{t("docker.summary", {
|
||||
apps: String(totalDetected),
|
||||
integrations: String(dockerIntegrations.length),
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<IntegrationMultiSelectGrid
|
||||
selectedKinds={selectedKinds}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
detectedKinds={detectedKinds}
|
||||
onboarding
|
||||
/>
|
||||
|
||||
{dockerApps.length > 0 && (
|
||||
<Paper withBorder p="sm" radius="md">
|
||||
<Stack gap="xs">
|
||||
<Group
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => setAppsExpanded((prev) => !prev)}
|
||||
>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("docker.appSectionLabel", { count: String(selectedAppCount) })}
|
||||
</Text>
|
||||
<IconChevronDown
|
||||
size={16}
|
||||
color="var(--mantine-color-dimmed)"
|
||||
style={{
|
||||
transform: appsExpanded ? "rotate(180deg)" : undefined,
|
||||
transition: "transform 200ms ease",
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
<Collapse expanded={appsExpanded}>
|
||||
<SimpleGrid cols={{ base: 3, xs: 4, sm: 5 }} spacing="xs">
|
||||
{dockerApps.map((app) => {
|
||||
const isSelected = selectedAppIds.has(app.containerId);
|
||||
return (
|
||||
<Card
|
||||
key={app.containerId}
|
||||
h={80}
|
||||
p="xs"
|
||||
withBorder
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
borderColor: isSelected ? "var(--mantine-color-blue-6)" : undefined,
|
||||
borderWidth: isSelected ? 2 : undefined,
|
||||
opacity: isSelected ? 1 : 0.6,
|
||||
}}
|
||||
onClick={() => handleToggleApp(app.containerId)}
|
||||
>
|
||||
<Stack justify="space-between" h="100%" gap={4} align="center">
|
||||
<Group justify="space-between" w="100%" wrap="nowrap">
|
||||
<Text size="xs" fw={500} lineClamp={1} style={{ flex: 1, minWidth: 0 }}>
|
||||
{app.containerName}
|
||||
</Text>
|
||||
{isSelected && <IconCheck size={14} color="var(--mantine-color-blue-6)" />}
|
||||
</Group>
|
||||
<Avatar size="sm" radius="sm" src={app.iconUrl} styles={{ image: { objectFit: "contain" } }}>
|
||||
{app.containerName.at(0)?.toUpperCase()}
|
||||
</Avatar>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Group justify="space-between">
|
||||
<Button variant="subtle" onClick={() => void finishSetupAsync()}>
|
||||
{t("action.skip")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleContinueToConfig}
|
||||
disabled={selectedKinds.length === 0 || undefined}
|
||||
rightSection={<IconArrowRight size={16} stroke={1.5} />}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
{t("action.continue")} {selectedKinds.length > 0 && `(${selectedKinds.length})`}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</InitStepCard>
|
||||
);
|
||||
};
|
||||
|
||||
interface ConfigurePhaseProps {
|
||||
selectedKinds: IntegrationKind[];
|
||||
currentIndex: number;
|
||||
drafts: Map<IntegrationKind, IntegrationDraft>;
|
||||
baseHost: string;
|
||||
setBaseHost: (value: string) => void;
|
||||
urlMode: UrlTemplateMode;
|
||||
setUrlMode: (value: UrlTemplateMode) => void;
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
const ConfigurePhase = ({
|
||||
selectedKinds,
|
||||
currentIndex,
|
||||
drafts,
|
||||
baseHost,
|
||||
setBaseHost,
|
||||
urlMode,
|
||||
setUrlMode,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
onSkip,
|
||||
}: ConfigurePhaseProps) => {
|
||||
const t = useScopedI18n("init.step.integrations");
|
||||
const [helpersOpen, { toggle: toggleHelpers }] = useDisclosure(false);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- currentIndex is always within bounds of selectedKinds
|
||||
const currentKind = selectedKinds[currentIndex]!;
|
||||
const draft = drafts.get(currentKind);
|
||||
const progress = ((currentIndex + 1) / selectedKinds.length) * 100;
|
||||
const computedUrl = baseHost
|
||||
? buildIntegrationUrl(currentKind, baseHost, urlMode, draft?.dockerPort ?? undefined)
|
||||
: draft?.initialUrl;
|
||||
|
||||
return (
|
||||
<InitStepCard>
|
||||
<Stack>
|
||||
<Group justify="space-between" align="center">
|
||||
<Group gap="xs">
|
||||
<IntegrationAvatar kind={currentKind} size="sm" />
|
||||
<Text fw={500}>{getIntegrationName(currentKind)}</Text>
|
||||
</Group>
|
||||
<Tooltip label={t("configure.helpers.tooltip")}>
|
||||
<ActionIcon variant={helpersOpen ? "light" : "subtle"} color="gray" onClick={toggleHelpers}>
|
||||
<IconSettings size={18} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
<Stack gap={4}>
|
||||
<Progress value={progress} size="sm" />
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{t("configure.progress", { current: String(currentIndex + 1), total: String(selectedKinds.length) })}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Collapse expanded={helpersOpen}>
|
||||
<Stack
|
||||
gap="xs"
|
||||
p="xs"
|
||||
style={{ background: "var(--mantine-color-default-hover)", borderRadius: "var(--mantine-radius-sm)" }}
|
||||
>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("configure.helpers.description")}
|
||||
</Text>
|
||||
<Group gap="sm" grow>
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder={t("urlTemplate.placeholder")}
|
||||
value={baseHost}
|
||||
onChange={(e) => setBaseHost(e.currentTarget.value)}
|
||||
/>
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={urlMode}
|
||||
onChange={(value) => setUrlMode(value as UrlTemplateMode)}
|
||||
data={[
|
||||
{ label: t("urlTemplate.mode.subdomain"), value: "subdomain" },
|
||||
{ label: t("urlTemplate.mode.hostPort"), value: "hostPort" },
|
||||
]}
|
||||
style={{ flex: "0 0 auto" }}
|
||||
/>
|
||||
</Group>
|
||||
{baseHost && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("urlTemplate.preview", {
|
||||
example: buildIntegrationUrl(currentKind, baseHost, urlMode, draft?.dockerPort ?? undefined),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
|
||||
<NewIntegrationForm
|
||||
key={currentKind}
|
||||
kind={currentKind}
|
||||
initialUrl={computedUrl}
|
||||
initialName={draft?.initialName}
|
||||
onSuccess={onSuccess}
|
||||
onCancel={onCancel}
|
||||
onSkip={onSkip}
|
||||
isOnboarding
|
||||
/>
|
||||
</Stack>
|
||||
</InitStepCard>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
"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 type { z } from "zod/v4";
|
||||
|
||||
@@ -13,8 +13,6 @@ import type { TranslationObject } from "@homarr/translation";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { settingsInitSchema } from "@homarr/validation/settings";
|
||||
|
||||
import { InitStepCard } from "../../_components/init-step-card";
|
||||
|
||||
export const InitSettings = () => {
|
||||
const tSection = useScopedI18n("management.page.settings.section");
|
||||
const t = useI18n();
|
||||
@@ -31,8 +29,8 @@ export const InitSettings = () => {
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmitAsync)}>
|
||||
<Stack w="100%">
|
||||
<InitStepCard>
|
||||
<Stack>
|
||||
<Card w={64 * 12 + 8} maw="90vw">
|
||||
<Stack gap="sm">
|
||||
<Text fw={500}>{tSection("analytics.title")}</Text>
|
||||
|
||||
@@ -40,8 +38,8 @@ export const InitSettings = () => {
|
||||
<AnalyticsRow kind="general" {...form.getInputProps("analytics.enableGeneral", { type: "checkbox" })} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</InitStepCard>
|
||||
<InitStepCard>
|
||||
</Card>
|
||||
<Card w={64 * 12 + 8} maw="90vw">
|
||||
<Stack gap="sm">
|
||||
<Text fw={500}>{tSection("crawlingAndIndexing.title")}</Text>
|
||||
|
||||
@@ -64,7 +62,7 @@ export const InitSettings = () => {
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</InitStepCard>
|
||||
</Card>
|
||||
|
||||
<Button type="submit" loading={form.submitting} rightSection={<IconArrowRight size={16} stroke={1.5} />}>
|
||||
{t("common.action.continue")}
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { Stack, Text } from "@mantine/core";
|
||||
import { IconFileImport, IconJson, IconPlayerPlay } from "@tabler/icons-react";
|
||||
import { Card, Stack, Text } from "@mantine/core";
|
||||
import { IconFileImport, IconPlayerPlay } from "@tabler/icons-react";
|
||||
|
||||
import { getMantineColor } from "@homarr/common";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { InitStepCard } from "../../_components/init-step-card";
|
||||
import { InitStartButton } from "./next-button";
|
||||
|
||||
export const InitStart = async () => {
|
||||
const tStart = await getScopedI18n("init.step.start");
|
||||
|
||||
return (
|
||||
<InitStepCard>
|
||||
<Card w={64 * 6} maw="90vw">
|
||||
<Stack>
|
||||
<Text>{tStart("description")}</Text>
|
||||
|
||||
@@ -23,19 +22,11 @@ export const InitStart = async () => {
|
||||
</InitStartButton>
|
||||
<InitStartButton
|
||||
preferredStep="import"
|
||||
importMode="legacy"
|
||||
icon={<IconFileImport color={getMantineColor("cyan", 6)} size={16} stroke={1.5} />}
|
||||
>
|
||||
{tStart("action.importOldmarr")}
|
||||
</InitStartButton>
|
||||
<InitStartButton
|
||||
preferredStep="import"
|
||||
importMode="config"
|
||||
icon={<IconJson color={getMantineColor("blue", 6)} size={16} stroke={1.5} />}
|
||||
>
|
||||
{tStart("action.importConfig")}
|
||||
</InitStartButton>
|
||||
</Stack>
|
||||
</InitStepCard>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -7,28 +7,15 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import type { OnboardingStep } from "@homarr/definitions";
|
||||
|
||||
import { clearInitImportMode, setInitImportMode, type InitImportMode } from "../import/use-init-import-mode";
|
||||
|
||||
interface InitStartButtonProps {
|
||||
icon: ReactNode;
|
||||
preferredStep: OnboardingStep | undefined;
|
||||
importMode?: InitImportMode;
|
||||
}
|
||||
|
||||
export const InitStartButton = ({
|
||||
preferredStep,
|
||||
icon,
|
||||
children,
|
||||
importMode,
|
||||
}: PropsWithChildren<InitStartButtonProps>) => {
|
||||
export const InitStartButton = ({ preferredStep, icon, children }: PropsWithChildren<InitStartButtonProps>) => {
|
||||
const { mutateAsync } = clientApi.onboard.nextStep.useMutation();
|
||||
|
||||
const handleClickAsync = async () => {
|
||||
if (importMode) {
|
||||
setInitImportMode(importMode);
|
||||
} else {
|
||||
clearInitImportMode();
|
||||
}
|
||||
await mutateAsync({ preferredStep });
|
||||
await revalidatePathActionAsync("/init");
|
||||
};
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Stack, TextInput } from "@mantine/core";
|
||||
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { signIn } from "@homarr/auth/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { UserCreatePasswordFields } from "@homarr/forms-collection";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { CustomPasswordInput } from "@homarr/ui";
|
||||
import { userInitSchema } from "@homarr/validation/user";
|
||||
|
||||
export const InitUserForm = () => {
|
||||
@@ -59,10 +59,12 @@ export const InitUserForm = () => {
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<TextInput label={t("field.username.label")} {...form.getInputProps("username")} />
|
||||
<UserCreatePasswordFields
|
||||
passwordInputProps={form.getInputProps("password")}
|
||||
confirmPasswordInputProps={form.getInputProps("confirmPassword")}
|
||||
<CustomPasswordInput
|
||||
withPasswordRequirements
|
||||
label={t("field.password.label")}
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
<PasswordInput label={t("field.passwordConfirm.label")} {...form.getInputProps("confirmPassword")} />
|
||||
<Button type="submit" fullWidth loading={isPending}>
|
||||
{t("action.create")}
|
||||
</Button>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { InitStepCard } from "../../_components/init-step-card";
|
||||
import { Card } from "@mantine/core";
|
||||
|
||||
import { InitUserForm } from "./init-user-form";
|
||||
|
||||
export const InitUser = () => {
|
||||
return (
|
||||
<InitStepCard>
|
||||
<Card w={64 * 6} maw="90vw">
|
||||
<InitUserForm />
|
||||
</InitStepCard>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -9,12 +9,10 @@ import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { CurrentColorSchemeCombobox } from "~/components/color-scheme/current-color-scheme-combobox";
|
||||
import { CurrentLanguageCombobox } from "~/components/language/current-language-combobox";
|
||||
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
|
||||
import { onboardingContentWidth, onboardingMaxWidth } from "./_constants";
|
||||
import { BackToStart } from "./_steps/back";
|
||||
import { InitFinish } from "./_steps/finish/init-finish";
|
||||
import { InitGroup } from "./_steps/group/init-group";
|
||||
import { InitImport } from "./_steps/import/init-import";
|
||||
import { InitIntegrations } from "./_steps/integrations/init-integrations";
|
||||
import { InitSettings } from "./_steps/settings/init-settings";
|
||||
import { InitStart } from "./_steps/start/init-start";
|
||||
import { InitUser } from "./_steps/user/init-user";
|
||||
@@ -25,7 +23,6 @@ const stepComponents: Record<OnboardingStep, null | (() => MaybePromise<JSX.Elem
|
||||
user: InitUser,
|
||||
group: InitGroup,
|
||||
settings: InitSettings,
|
||||
integrations: InitIntegrations,
|
||||
finish: InitFinish,
|
||||
};
|
||||
|
||||
@@ -38,7 +35,7 @@ export default async function InitPage() {
|
||||
return (
|
||||
<Box mih="100dvh">
|
||||
<Center>
|
||||
<Stack align="center" mt="xl" w={onboardingContentWidth} maw={onboardingMaxWidth}>
|
||||
<Stack align="center" mt="xl">
|
||||
<HomarrLogoWithTitle size="lg" />
|
||||
<Stack gap={6} align="center">
|
||||
<Title order={3} fw={400} ta="center">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
|
||||
import "@gfazioli/mantine-onboarding-tour/styles.css";
|
||||
import "@homarr/notifications/styles.css";
|
||||
import "@homarr/spotlight/styles.css";
|
||||
import "@homarr/ui/styles.css";
|
||||
@@ -26,7 +25,6 @@ import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
|
||||
|
||||
import { Analytics } from "~/components/layout/analytics";
|
||||
import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation";
|
||||
|
||||
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
|
||||
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
|
||||
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||
|
||||
@@ -11,7 +11,6 @@ import type { inferSearchParamsFromSchema } from "@homarr/common/types";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
import { Link, SearchInput, TablePagination } from "@homarr/ui";
|
||||
|
||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
@@ -46,11 +45,9 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
title={t("page.list.title")}
|
||||
primaryAction={
|
||||
canCreate ? (
|
||||
<TourTarget id="manage-apps-create">
|
||||
<MobileAffixButton component={Link} href="/manage/apps/new">
|
||||
{t("page.create.title")}
|
||||
</MobileAffixButton>
|
||||
</TourTarget>
|
||||
<MobileAffixButton component={Link} href="/manage/apps/new">
|
||||
{t("page.create.title")}
|
||||
</MobileAffixButton>
|
||||
) : undefined
|
||||
}
|
||||
toolbar={<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand />}
|
||||
@@ -59,13 +56,11 @@ export default async function AppsPage(props: AppsPageProps) {
|
||||
>
|
||||
{apps.length === 0 && <AppNoResults />}
|
||||
{apps.length > 0 && (
|
||||
<TourTarget id="manage-apps-list">
|
||||
<Stack gap="sm">
|
||||
{apps.map((app) => (
|
||||
<AppCard key={app.id} app={app} />
|
||||
))}
|
||||
</Stack>
|
||||
</TourTarget>
|
||||
<Stack gap="sm">
|
||||
{apps.map((app) => (
|
||||
<AppCard key={app.id} app={app} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</ManagePageLayout>
|
||||
);
|
||||
|
||||
+3
-19
@@ -2,14 +2,14 @@
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Menu } from "@mantine/core";
|
||||
import { IconCopy, IconDeviceMobile, IconDownload, IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
|
||||
import { IconCopy, IconDeviceMobile, IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { DuplicateBoardModal, ExportBoardModal } from "@homarr/modals-collection";
|
||||
import { DuplicateBoardModal } from "@homarr/modals-collection";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
@@ -28,10 +28,7 @@ interface BoardCardMenuDropdownProps {
|
||||
}
|
||||
|
||||
export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => {
|
||||
const t = useScopedI18n("management.page.board.action" as never) as unknown as (
|
||||
key: string,
|
||||
values?: Record<string, unknown>,
|
||||
) => string;
|
||||
const t = useScopedI18n("management.page.board.action");
|
||||
const tCommon = useScopedI18n("common");
|
||||
|
||||
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
|
||||
@@ -39,7 +36,6 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
||||
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const { openModal: openDuplicateModal } = useModalAction(DuplicateBoardModal);
|
||||
const { openModal: openExportModal } = useModalAction(ExportBoardModal);
|
||||
|
||||
const setHomeBoardMutation = clientApi.board.setHomeBoard.useMutation({
|
||||
onSettled: async () => {
|
||||
@@ -91,15 +87,6 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
||||
});
|
||||
}, [board.id, board.name, openDuplicateModal]);
|
||||
|
||||
const handleExportBoard = useCallback(() => {
|
||||
openExportModal({
|
||||
board: {
|
||||
id: board.id,
|
||||
name: board.name,
|
||||
},
|
||||
});
|
||||
}, [board.id, board.name, openExportModal]);
|
||||
|
||||
return (
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={handleSetHomeBoard} leftSection={<IconHome {...iconProps} />}>
|
||||
@@ -108,9 +95,6 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
|
||||
<Menu.Item onClick={handleSetMobileHomeBoard} leftSection={<IconDeviceMobile {...iconProps} />}>
|
||||
{t("setMobileHomeBoard.label")}
|
||||
</Menu.Item>
|
||||
<Menu.Item onClick={handleExportBoard} leftSection={<IconDownload {...iconProps} />}>
|
||||
{t("export.label")}
|
||||
</Menu.Item>
|
||||
{session?.user.permissions.includes("board-create") && (
|
||||
<Menu.Item onClick={handleDuplicateBoard} leftSection={<IconCopy {...iconProps} />}>
|
||||
{t("duplicate.label")}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useI18n } from "@homarr/translation/client";
|
||||
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
|
||||
|
||||
export const CreateBoardButton = () => {
|
||||
const t = useI18n() as unknown as (key: string) => string;
|
||||
const t = useI18n();
|
||||
const { openModal: openAddModal } = useModalAction(AddBoardModal);
|
||||
const { openModal: openImportModal } = useModalAction(ImportBoardModal);
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { Link, UserAvatar } from "@homarr/ui";
|
||||
|
||||
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
|
||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { BoardCardMenuDropdown } from "./_components/board-card-menu-dropdown";
|
||||
@@ -43,26 +42,18 @@ export default async function ManageBoardsPage() {
|
||||
return (
|
||||
<ManagePageLayout
|
||||
title={t("title")}
|
||||
primaryAction={
|
||||
canCreateBoards ? (
|
||||
<TourTarget id="manage-boards-create">
|
||||
<CreateBoardButton />
|
||||
</TourTarget>
|
||||
) : undefined
|
||||
}
|
||||
primaryAction={canCreateBoards ? <CreateBoardButton /> : undefined}
|
||||
floatingPrimaryAction={canCreateBoards}
|
||||
>
|
||||
{boards.length === 0 && <NoResults icon={IconLayoutDashboard} title={t("noResults.title")} />}
|
||||
{boards.length > 0 && (
|
||||
<TourTarget id="manage-boards-list">
|
||||
<Grid>
|
||||
{boards.map((board) => (
|
||||
<GridCol span={{ base: 12, md: 6 }} key={board.id}>
|
||||
<BoardCard board={board} />
|
||||
</GridCol>
|
||||
))}
|
||||
</Grid>
|
||||
</TourTarget>
|
||||
<Grid>
|
||||
{boards.map((board) => (
|
||||
<GridCol span={{ base: 12, md: 6 }} key={board.id}>
|
||||
<BoardCard board={board} />
|
||||
</GridCol>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</ManagePageLayout>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
-26
@@ -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";
|
||||
|
||||
import { startTransition, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Button,
|
||||
Checkbox,
|
||||
Collapse,
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
} 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 { clientApi } from "@homarr/api/client";
|
||||
@@ -27,7 +27,6 @@ import type { IntegrationKind } from "@homarr/definitions";
|
||||
import {
|
||||
getAllSecretKindOptions,
|
||||
getIconUrl,
|
||||
getIntegrationApiKeyUrl,
|
||||
getIntegrationDefaultUrl,
|
||||
getIntegrationName,
|
||||
integrationDefs,
|
||||
@@ -46,13 +45,9 @@ import { IntegrationTestConnectionError } from "../_components/test-connection/i
|
||||
import type { AnyMappedTestConnectionError } from "../_components/test-connection/types";
|
||||
|
||||
interface NewIntegrationFormProps {
|
||||
kind: IntegrationKind;
|
||||
initialUrl?: string;
|
||||
initialName?: string;
|
||||
onSuccess: () => void;
|
||||
onCancel?: () => void;
|
||||
onSkip?: () => void;
|
||||
isOnboarding?: boolean;
|
||||
searchParams: Partial<z.infer<typeof integrationCreateSchema>> & {
|
||||
kind: IntegrationKind;
|
||||
};
|
||||
}
|
||||
|
||||
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 = ({
|
||||
kind,
|
||||
initialUrl,
|
||||
initialName,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
onSkip,
|
||||
isOnboarding = false,
|
||||
}: NewIntegrationFormProps) => {
|
||||
export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => {
|
||||
const t = useI18n();
|
||||
const secretKinds = getAllSecretKindOptions(kind);
|
||||
const secretKinds = getAllSecretKindOptions(searchParams.kind);
|
||||
const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url"));
|
||||
const router = useRouter();
|
||||
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) {
|
||||
// Placeholder Url, replaced with origin of the secret Url on submit
|
||||
url = "http://localhost";
|
||||
}
|
||||
const form = useZodForm(formSchema, {
|
||||
initialValues: {
|
||||
name: initialName ?? getIntegrationName(kind),
|
||||
name: searchParams.name ?? getIntegrationName(searchParams.kind),
|
||||
url,
|
||||
secrets: secretKinds[0].map((kind) => ({
|
||||
kind,
|
||||
value: "",
|
||||
})),
|
||||
attemptSearchEngineCreation: !isOnboarding,
|
||||
hasApp: !isOnboarding,
|
||||
appHref: isOnboarding ? null : url,
|
||||
attemptSearchEngineCreation: true,
|
||||
hasApp: false,
|
||||
appHref: url,
|
||||
appId: null,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: createIntegrationAsync, isPending: isCreatePending } = clientApi.integration.create.useMutation({
|
||||
const { mutateAsync: createIntegrationAsync, isPending } = clientApi.integration.create.useMutation({
|
||||
async onSuccess() {
|
||||
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 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
|
||||
: 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 app = hasApp
|
||||
@@ -153,21 +106,49 @@ export const NewIntegrationForm = ({
|
||||
: {
|
||||
name: values.name,
|
||||
href: hasCustomHref ? appHref : url,
|
||||
iconUrl: getIconUrl(kind),
|
||||
iconUrl: getIconUrl(searchParams.kind),
|
||||
description: null,
|
||||
pingUrl: url,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
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 =
|
||||
integrationCategories.includes("search") && !integrationCategories.includes("mediaSearch");
|
||||
const supportsSearchEngine = integrationDefs[searchParams.kind].category.flat().includes("search");
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((value) => void handleSubmitAsync(value))}>
|
||||
@@ -194,39 +175,27 @@ export const NewIntegrationForm = ({
|
||||
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
<ApiKeySettingsLink kind={kind} url={form.values.url} />
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
|
||||
{error !== null && <IntegrationTestConnectionError error={error} url={form.values.url} />}
|
||||
|
||||
{!isOnboarding && supportsSearchEngine && (
|
||||
{supportsSearchEngine && (
|
||||
<Checkbox
|
||||
label={t("integration.field.attemptSearchEngineCreation.label")}
|
||||
description={t("integration.field.attemptSearchEngineCreation.description", {
|
||||
kind: getIntegrationName(kind),
|
||||
kind: getIntegrationName(searchParams.kind),
|
||||
})}
|
||||
{...form.getInputProps("attemptSearchEngineCreation", { type: "checkbox" })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isOnboarding && <AppForm form={form} canCreateApps={canCreateApps} />}
|
||||
<AppForm form={form} canCreateApps={canCreateApps} />
|
||||
|
||||
<Group justify="end" align="center">
|
||||
{onCancel ? (
|
||||
<Button variant="default" onClick={onCancel}>
|
||||
{t("common.action.backToOverview")}
|
||||
</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 variant="default" component={Link} href="/manage/integrations">
|
||||
{t("common.action.backToOverview")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("integration.testConnection.action.create")}
|
||||
</Button>
|
||||
@@ -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<
|
||||
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 { auth } from "@homarr/auth/next";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { getIntegrationName, integrationKinds } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { IntegrationAvatar } from "@homarr/ui";
|
||||
import type { integrationCreateSchema } from "@homarr/validation/integration";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { env } from "~/env";
|
||||
import { IntegrationNewFormWrapper } from "./_integration-new-form-wrapper";
|
||||
import { IntegrationNewSelectGrid } from "./_integration-new-select-grid";
|
||||
import { NewIntegrationForm } from "./_integration-new-form";
|
||||
|
||||
interface NewIntegrationPageProps {
|
||||
searchParams: Promise<{
|
||||
kind?: string;
|
||||
url?: string;
|
||||
name?: string;
|
||||
}>;
|
||||
searchParams: Promise<
|
||||
Partial<z.infer<typeof integrationCreateSchema>> & {
|
||||
kind: IntegrationKind;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (!result.success) {
|
||||
const t = await getScopedI18n("integration");
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Container>
|
||||
<Stack>
|
||||
<Title>{t("action.create")}</Title>
|
||||
<IntegrationNewSelectGrid enableMockIntegration={env.UNSAFE_ENABLE_MOCK_INTEGRATION} />
|
||||
</Stack>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
notFound();
|
||||
}
|
||||
|
||||
const tCreate = await getScopedI18n("integration.page.create");
|
||||
@@ -57,7 +45,7 @@ export default async function IntegrationsNewPage(props: NewIntegrationPageProps
|
||||
<IntegrationAvatar kind={currentKind} size="md" />
|
||||
<Title>{tCreate("title", { name: getIntegrationName(currentKind) })}</Title>
|
||||
</Group>
|
||||
<IntegrationNewFormWrapper kind={currentKind} initialUrl={searchParams.url} initialName={searchParams.name} />
|
||||
<NewIntegrationForm searchParams={searchParams} />
|
||||
</Stack>
|
||||
</Container>
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Fragment } from "react";
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
import {
|
||||
AccordionControl,
|
||||
@@ -7,8 +8,12 @@ import {
|
||||
ActionIcon,
|
||||
ActionIconGroup,
|
||||
Anchor,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Menu,
|
||||
MenuDropdown,
|
||||
MenuTarget,
|
||||
Stack,
|
||||
Table,
|
||||
TableTbody,
|
||||
@@ -18,7 +23,7 @@ import {
|
||||
TableTr,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { IconPencil, IconPlugX } from "@tabler/icons-react";
|
||||
import { IconChevronDown, IconPencil, IconPlugX } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
@@ -29,12 +34,13 @@ import { getIntegrationName } from "@homarr/definitions";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { CountBadge, IntegrationAvatar, Link } from "@homarr/ui";
|
||||
|
||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
import { ManageMobilePrimaryAction } from "~/components/manage/manage-mobile-primary-action";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { NoResults } from "~/components/no-results";
|
||||
import { env } from "~/env";
|
||||
import { ActiveTabAccordion } from "../../../../components/active-tab-accordion";
|
||||
import { DeleteIntegrationActionButton } from "./_integration-buttons";
|
||||
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
|
||||
import classes from "./page.module.css";
|
||||
|
||||
interface IntegrationsPageProps {
|
||||
@@ -61,22 +67,41 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
title={t("page.list.title")}
|
||||
primaryAction={
|
||||
canCreateIntegrations ? (
|
||||
<TourTarget id="manage-integrations-create">
|
||||
<MobileAffixButton component={Link} href="/manage/integrations/new">
|
||||
{t("action.create")}
|
||||
</MobileAffixButton>
|
||||
</TourTarget>
|
||||
<ManageMobilePrimaryAction>
|
||||
<IntegrationSelectMenu>
|
||||
<MenuTarget>
|
||||
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>{t("action.create")}</Button>
|
||||
</MenuTarget>
|
||||
</IntegrationSelectMenu>
|
||||
</ManageMobilePrimaryAction>
|
||||
) : undefined
|
||||
}
|
||||
floatingPrimaryAction={canCreateIntegrations}
|
||||
>
|
||||
<TourTarget id="manage-integrations-list">
|
||||
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
|
||||
</TourTarget>
|
||||
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
integrations: RouterOutputs["integration"]["all"];
|
||||
activeTab?: IntegrationKind;
|
||||
@@ -109,12 +134,12 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
||||
|
||||
return (
|
||||
<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}>
|
||||
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} radius="sm" />}>
|
||||
<Group>
|
||||
<Text>{getIntegrationName(kind)}</Text>
|
||||
<CountBadge count={kindIntegrations.length} />
|
||||
<CountBadge count={integrations.length} />
|
||||
</Group>
|
||||
</AccordionControl>
|
||||
<AccordionPanel>
|
||||
@@ -127,7 +152,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{kindIntegrations.map((integration) => (
|
||||
{integrations.map((integration) => (
|
||||
<TableTr key={integration.id}>
|
||||
<TableTd>{integration.name}</TableTd>
|
||||
<TableTd>
|
||||
@@ -148,7 +173,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<DeleteIntegrationActionButton integration={integration} count={kindIntegrations.length} />
|
||||
<DeleteIntegrationActionButton integration={integration} count={integrations.length} />
|
||||
</ActionIconGroup>
|
||||
)}
|
||||
</Group>
|
||||
@@ -159,9 +184,9 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
||||
</Table>
|
||||
|
||||
<Stack gap="xs" hiddenFrom="md">
|
||||
{kindIntegrations.map((integration, integrationIndex) => (
|
||||
{integrations.map((integration, index) => (
|
||||
<Fragment key={integration.id}>
|
||||
{integrationIndex !== 0 && <Divider />}
|
||||
{index !== 0 && <Divider />}
|
||||
<Stack gap={0}>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Text>{integration.name}</Text>
|
||||
@@ -176,7 +201,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
|
||||
>
|
||||
<IconPencil size={16} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
<DeleteIntegrationActionButton integration={integration} count={kindIntegrations.length} />
|
||||
<DeleteIntegrationActionButton integration={integration} count={integrations.length} />
|
||||
</ActionIconGroup>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
IconBrandTablerFilled,
|
||||
IconCertificate,
|
||||
IconClipboardListFilled,
|
||||
IconDatabaseExport,
|
||||
IconDirectionsFilled,
|
||||
IconGitFork,
|
||||
IconHelpSquareRoundedFilled,
|
||||
@@ -37,7 +36,6 @@ import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
import type { NavigationLink } from "~/components/layout/navigation";
|
||||
import { MainNavigation } from "~/components/layout/navigation";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
import { ManageTourProvider } from "~/components/onboarding/manage-tour";
|
||||
|
||||
export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
const t = await getScopedI18n("management.navbar");
|
||||
@@ -47,13 +45,11 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
label: t("items.home"),
|
||||
icon: IconHomeFilled,
|
||||
href: "/manage",
|
||||
"data-onboarding-tour-id": "manage-welcome",
|
||||
},
|
||||
{
|
||||
icon: IconLayoutDashboardFilled,
|
||||
href: "/manage/boards",
|
||||
label: t("items.boards"),
|
||||
"data-onboarding-tour-id": "manage-boards",
|
||||
},
|
||||
{
|
||||
icon: IconBox,
|
||||
@@ -63,14 +59,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
iconProps: {
|
||||
strokeWidth: 2.5,
|
||||
},
|
||||
"data-onboarding-tour-id": "manage-apps",
|
||||
},
|
||||
{
|
||||
icon: IconAffiliateFilled,
|
||||
href: "/manage/integrations",
|
||||
label: t("items.integrations"),
|
||||
hidden: !session,
|
||||
"data-onboarding-tour-id": "manage-integrations",
|
||||
},
|
||||
{
|
||||
icon: IconSearch,
|
||||
@@ -80,20 +74,17 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
iconProps: {
|
||||
strokeWidth: 2.5,
|
||||
},
|
||||
"data-onboarding-tour-id": "manage-search-engines",
|
||||
},
|
||||
{
|
||||
icon: IconPhotoFilled,
|
||||
href: "/manage/medias",
|
||||
label: t("items.medias"),
|
||||
hidden: !session,
|
||||
"data-onboarding-tour-id": "manage-medias",
|
||||
},
|
||||
{
|
||||
icon: IconUserFilled,
|
||||
label: t("items.users.label"),
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
"data-onboarding-tour-id": "manage-users",
|
||||
items: [
|
||||
{
|
||||
label: t("items.users.items.manage"),
|
||||
@@ -155,12 +146,6 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
href: "/manage/tools/tasks",
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
{
|
||||
label: (t as unknown as (key: string) => string)("items.tools.items.importExport"),
|
||||
icon: IconDatabaseExport,
|
||||
href: "/manage/tools/import-export",
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -168,7 +153,6 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
href: "/manage/settings",
|
||||
icon: IconSettingsFilled,
|
||||
hidden: !session?.user.permissions.includes("admin"),
|
||||
"data-onboarding-tour-id": "manage-settings",
|
||||
},
|
||||
{
|
||||
label: t("items.help.label"),
|
||||
@@ -207,17 +191,11 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
||||
},
|
||||
];
|
||||
|
||||
const isAdmin = session?.user.permissions.includes("admin") ?? false;
|
||||
|
||||
const shell = (
|
||||
return (
|
||||
<ClientShell hasNavigation>
|
||||
<MainHeader></MainHeader>
|
||||
<MainNavigation links={navigationLinks}></MainNavigation>
|
||||
<AppShellMain>{children}</AppShellMain>
|
||||
</ClientShell>
|
||||
);
|
||||
|
||||
if (!session) return shell;
|
||||
|
||||
return <ManageTourProvider isAdmin={isAdmin}>{shell}</ManageTourProvider>;
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { NumberInput, Switch, TextInput } from "@mantine/core";
|
||||
|
||||
import type { ServerSettings } from "@homarr/server-settings";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { CommonSettingsForm } from "./common-form";
|
||||
|
||||
export const DockerSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["docker"] }) => {
|
||||
const tDocker = useScopedI18n("management.page.settings.section.docker" as never) as unknown as (
|
||||
key: string,
|
||||
) => string;
|
||||
|
||||
return (
|
||||
<CommonSettingsForm settingKey="docker" defaultValues={defaultValues}>
|
||||
{(form) => (
|
||||
<>
|
||||
<TextInput
|
||||
{...form.getInputProps("targetBoardName")}
|
||||
value={form.getInputProps("targetBoardName").value ?? ""}
|
||||
label={tDocker("targetBoardName.label")}
|
||||
description={tDocker("targetBoardName.description")}
|
||||
/>
|
||||
<Switch
|
||||
{...form.getInputProps("readHomepageLabels", { type: "checkbox" })}
|
||||
label={tDocker("readHomepageLabels.label")}
|
||||
description={tDocker("readHomepageLabels.description")}
|
||||
/>
|
||||
<Switch
|
||||
{...form.getInputProps("createIntegrations", { type: "checkbox" })}
|
||||
label={tDocker("createIntegrations.label")}
|
||||
description={tDocker("createIntegrations.description")}
|
||||
/>
|
||||
<Switch
|
||||
{...form.getInputProps("pruneRemoved", { type: "checkbox" })}
|
||||
label={tDocker("pruneRemoved.label")}
|
||||
description={tDocker("pruneRemoved.description")}
|
||||
/>
|
||||
<NumberInput
|
||||
{...form.getInputProps("defaultItemWidth")}
|
||||
label={tDocker("defaultItemWidth.label")}
|
||||
description={tDocker("defaultItemWidth.description")}
|
||||
min={1}
|
||||
max={10}
|
||||
/>
|
||||
<NumberInput
|
||||
{...form.getInputProps("defaultItemHeight")}
|
||||
label={tDocker("defaultItemHeight.label")}
|
||||
description={tDocker("defaultItemHeight.description")}
|
||||
min={1}
|
||||
max={10}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CommonSettingsForm>
|
||||
);
|
||||
};
|
||||
@@ -5,15 +5,12 @@ import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { env } from "@homarr/docker/env";
|
||||
|
||||
import { CrawlingAndIndexingSettings } from "~/app/[locale]/manage/settings/_components/crawling-and-indexing.settings";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { AnalyticsSettings } from "./_components/analytics.settings";
|
||||
import { AppearanceSettingsForm } from "./_components/appearance-settings-form";
|
||||
import { BoardSettingsForm } from "./_components/board-settings-form";
|
||||
import { CultureSettingsForm } from "./_components/culture-settings-form";
|
||||
import { DockerSettingsForm } from "./_components/docker-settings-form";
|
||||
import { SearchSettingsForm } from "./_components/search-settings-form";
|
||||
import { UserSettingsForm } from "./_components/user-settings-form";
|
||||
|
||||
@@ -62,12 +59,6 @@ export default async function SettingsPage() {
|
||||
<Title order={2}>{tSettings("section.culture.title")}</Title>
|
||||
<CultureSettingsForm defaultValues={serverSettings.culture} />
|
||||
</Stack>
|
||||
{env.ENABLE_DOCKER && (
|
||||
<Stack>
|
||||
<Title order={2}>{(tSettings as unknown as (key: string) => string)("section.docker.title")}</Title>
|
||||
<DockerSettingsForm defaultValues={serverSettings.docker} />
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,26 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
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 {
|
||||
IconCategoryPlus,
|
||||
IconDots,
|
||||
IconFileText,
|
||||
IconPlayerPlay,
|
||||
IconPlayerStop,
|
||||
IconRefresh,
|
||||
IconRotateClockwise,
|
||||
IconTrash,
|
||||
} 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 type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { humanFileSize, useTimeAgo } from "@homarr/common";
|
||||
import { useTimeAgo } from "@homarr/common";
|
||||
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 { AddDockerAppToHomarr } from "@homarr/modals-collection";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
@@ -30,30 +27,25 @@ import type { TablerIcon } from "@homarr/ui";
|
||||
import { OverflowBadge } from "@homarr/ui";
|
||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
|
||||
type DockerContainer = RouterOutputs["docker"]["getContainers"]["containers"][number];
|
||||
|
||||
const createContainerLogsPath = (container: Pick<DockerContainer, "id" | "name">) =>
|
||||
`/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}`;
|
||||
|
||||
const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[] => [
|
||||
const createColumns = (
|
||||
t: TranslationFunction,
|
||||
): MRT_ColumnDef<RouterOutputs["docker"]["getContainers"]["containers"][number]>[] => [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: t("docker.field.name.label"),
|
||||
Cell({ renderedCellValue, row }) {
|
||||
return (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Group gap="xs">
|
||||
<Avatar
|
||||
variant="outline"
|
||||
radius="sm"
|
||||
radius="lg"
|
||||
size="md"
|
||||
styles={{ image: { objectFit: "contain" } }}
|
||||
src={row.original.iconUrl}
|
||||
>
|
||||
{row.original.name.at(0)?.toUpperCase()}
|
||||
</Avatar>
|
||||
<Text truncate="end" maw={500}>
|
||||
{renderedCellValue}
|
||||
</Text>
|
||||
<Text>{renderedCellValue}</Text>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
@@ -84,11 +76,11 @@ const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[]
|
||||
maxSize: 200,
|
||||
Cell({ renderedCellValue, cell }) {
|
||||
return (
|
||||
<Tooltip label={cell.row.original.image} multiline maw={400}>
|
||||
<Box maw={200}>
|
||||
<Text truncate="end">{renderedCellValue}</Text>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<Box maw={200}>
|
||||
<Text truncate="end" title={cell.row.original.image}>
|
||||
{renderedCellValue}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -103,57 +95,13 @@ 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 tDocker = useScopedI18n("docker");
|
||||
const { data } = clientApi.docker.getContainers.useQuery(undefined, {
|
||||
initialData: { containers, timestamp },
|
||||
initialData,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
@@ -183,8 +131,6 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["
|
||||
enableColumnFilters: false,
|
||||
enablePagination: false,
|
||||
enableRowSelection: true,
|
||||
enableRowActions: true,
|
||||
positionActionsColumn: "last",
|
||||
positionToolbarAlertBanner: "top",
|
||||
enableTableFooter: false,
|
||||
enableBottomToolbar: false,
|
||||
@@ -195,10 +141,7 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["
|
||||
autoFocus: true,
|
||||
},
|
||||
|
||||
initialState: { density: "xs", showGlobalFilter: true, columnVisibility: { cpuUsage: false, memoryUsage: false } },
|
||||
mantineTableBodyCellProps: { style: { padding: "4px 8px" } },
|
||||
mantineTableHeadCellProps: { style: { padding: "4px 8px" } },
|
||||
renderRowActions: ({ row }: { row: MRT_Row<DockerContainer> }) => <ContainerRowMenu container={row.original} />,
|
||||
initialState: { density: "xs", showGlobalFilter: true },
|
||||
renderTopToolbarCustomActions: () => (
|
||||
<Button variant="default" rightSection={<IconRefresh size="1rem" />} onClick={() => mutate()} loading={isPending}>
|
||||
{tDocker("action.refresh.label")}
|
||||
@@ -231,104 +174,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 {
|
||||
selectedContainers: DockerContainer[];
|
||||
selectedContainers: RouterOutputs["docker"]["getContainers"]["containers"];
|
||||
}
|
||||
|
||||
const ContainerActionBar = ({ selectedContainers }: ContainerActionBarProps) => {
|
||||
const t = useScopedI18n("docker.action");
|
||||
const { openModal } = useModalAction(AddDockerAppToHomarr);
|
||||
const handleClick = () => {
|
||||
openModal({
|
||||
selectedContainers,
|
||||
});
|
||||
};
|
||||
|
||||
const selectedIds = selectedContainers.map((container) => container.id);
|
||||
|
||||
return (
|
||||
<Group gap="xs">
|
||||
{containerActions.map(({ action, icon, color }) => (
|
||||
<ContainerActionBarButton key={action} icon={icon} color={color} action={action} selectedIds={selectedIds} />
|
||||
))}
|
||||
<Button
|
||||
leftSection={<IconCategoryPlus />}
|
||||
color="red"
|
||||
onClick={() => openModal({ selectedContainers })}
|
||||
variant="light"
|
||||
>
|
||||
<ContainerActionBarButton icon={IconPlayerPlay} color="green" action="start" selectedIds={selectedIds} />
|
||||
<ContainerActionBarButton icon={IconPlayerStop} color="red" action="stop" selectedIds={selectedIds} />
|
||||
<ContainerActionBarButton icon={IconRotateClockwise} color="orange" action="restart" selectedIds={selectedIds} />
|
||||
<ContainerActionBarButton icon={IconTrash} color="red" action="remove" selectedIds={selectedIds} />
|
||||
<Button leftSection={<IconCategoryPlus />} color={"red"} onClick={handleClick} variant="light">
|
||||
{t("addToHomarr.label")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
.outerTerminal > div {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
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 fitAddon = new FitAddon();
|
||||
const terminal = new Terminal({
|
||||
cursorBlink: false,
|
||||
disableStdin: true,
|
||||
convertEol: true,
|
||||
fontSize: 14,
|
||||
});
|
||||
terminal.open(ref.current);
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminalRef.current = terminal;
|
||||
|
||||
isTerminalReadyRef.current = true;
|
||||
pendingLogsRef.current.forEach((data) => terminal.write(data));
|
||||
pendingLogsRef.current = [];
|
||||
terminal.refresh(0, terminal.rows - 1);
|
||||
|
||||
const fitTimeout = setTimeout(() => fitAddon.fit());
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => fitAddon.fit());
|
||||
resizeObserver.observe(ref.current);
|
||||
|
||||
return () => {
|
||||
clearTimeout(fitTimeout);
|
||||
resizeObserver.disconnect();
|
||||
isTerminalReadyRef.current = false;
|
||||
pendingLogsRef.current = [];
|
||||
fitAddon.dispose();
|
||||
terminal.dispose();
|
||||
terminalRef.current = null;
|
||||
};
|
||||
}, [writeToTerminal]);
|
||||
|
||||
return <Box ref={ref} className={classes.outerTerminal} h="100%" />;
|
||||
};
|
||||
@@ -1,124 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Avatar, Badge, Button, Card, Group, Stack, Table, Text, Title } from "@mantine/core";
|
||||
import { IconRefresh, IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
export const DockerDiscoveryPanel = () => {
|
||||
const tDiscovery = useScopedI18n("docker.discovery" as never) as unknown as (
|
||||
key: string,
|
||||
values?: Record<string, unknown>,
|
||||
) => string;
|
||||
const { data: preview, refetch, isLoading: isLoadingPreview, isFetched } =
|
||||
clientApi.docker.getDiscoveredPreview.useQuery(undefined, {
|
||||
enabled: false,
|
||||
});
|
||||
const { mutate: syncDiscovery, isPending: isSyncing } = clientApi.docker.syncDiscovery.useMutation({
|
||||
onSuccess(result) {
|
||||
showSuccessNotification({
|
||||
title: tDiscovery("sync.notification.success.title"),
|
||||
message: tDiscovery("sync.notification.success.message", {
|
||||
created: result.created,
|
||||
updated: result.updated,
|
||||
}),
|
||||
});
|
||||
void refetch();
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: tDiscovery("sync.notification.error.title"),
|
||||
message: tDiscovery("sync.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const services = preview ?? [];
|
||||
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Title order={3}>{tDiscovery("title")}</Title>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconSearch size={16} />}
|
||||
loading={isLoadingPreview}
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
{tDiscovery("preview.label")}
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
loading={isSyncing}
|
||||
onClick={() => syncDiscovery()}
|
||||
disabled={services.length === 0}
|
||||
>
|
||||
{tDiscovery("sync.label")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{!isFetched ? (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
{tDiscovery("preview.idle")}
|
||||
</Text>
|
||||
) : services.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
{tDiscovery("preview.empty")}
|
||||
</Text>
|
||||
) : (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{tDiscovery("field.name")}</Table.Th>
|
||||
<Table.Th>{tDiscovery("field.group")}</Table.Th>
|
||||
<Table.Th>{tDiscovery("field.href")}</Table.Th>
|
||||
<Table.Th>{tDiscovery("field.host")}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{services.map((service) => (
|
||||
<Table.Tr key={`${service.host}/${service.containerId}`}>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
{service.icon && (
|
||||
<Avatar size="sm" radius="sm" src={service.icon}>
|
||||
{service.name.charAt(0)}
|
||||
</Avatar>
|
||||
)}
|
||||
<Text size="sm">{service.name}</Text>
|
||||
{service.integrationKind && (
|
||||
<Badge size="xs" variant="light">
|
||||
{service.integrationKind}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="outline" size="sm">
|
||||
{service.group}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" lineClamp={1}>
|
||||
{service.href}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{service.host}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,138 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Alert, Button, Card, Group, List, Loader, Popover, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconDatabaseExport } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { ConfigSummaryList } from "~/components/config-import/config-summary-list";
|
||||
|
||||
const exportWarningPointKeys = [
|
||||
"management.page.importExport.export.warning.message",
|
||||
"management.page.importExport.export.warning.storage",
|
||||
"management.page.importExport.export.warning.sharing",
|
||||
] as const;
|
||||
|
||||
export const ExportConfigCard = () => {
|
||||
const t = useI18n() as unknown as (key: string, values?: Record<string, unknown>) => string;
|
||||
const { mutateAsync, isPending } = clientApi.config.exportFull.useMutation();
|
||||
const [popoverOpened, setPopoverOpened] = useState(false);
|
||||
|
||||
const { data: preview, isLoading: previewLoading } = clientApi.config.previewExport.useQuery(undefined, {
|
||||
enabled: popoverOpened,
|
||||
});
|
||||
|
||||
const closePopover = () => setPopoverOpened(false);
|
||||
|
||||
const runExport = async () => {
|
||||
await mutateAsync(undefined, {
|
||||
onSuccess({ content, filename }) {
|
||||
const blob = new Blob([content], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showSuccessNotification({
|
||||
title: t("management.page.importExport.export.notification.success.title"),
|
||||
message: t("management.page.importExport.export.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("management.page.importExport.export.notification.error.title"),
|
||||
message: t("management.page.importExport.export.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirmExport = () => {
|
||||
closePopover();
|
||||
void runExport();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Stack>
|
||||
<Stack gap="xs">
|
||||
<Title order={3}>{t("management.page.importExport.export.title")}</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("management.page.importExport.export.description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Popover
|
||||
opened={popoverOpened}
|
||||
onChange={setPopoverOpened}
|
||||
width={420}
|
||||
position="bottom-end"
|
||||
withArrow
|
||||
shadow="md"
|
||||
>
|
||||
<Popover.Target>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
leftSection={<IconDatabaseExport size="1rem" />}
|
||||
loading={isPending}
|
||||
disabled={isPending}
|
||||
onClick={() => setPopoverOpened(true)}
|
||||
>
|
||||
{t("management.page.importExport.export.action")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
variant="light"
|
||||
color="red"
|
||||
icon={<IconAlertTriangle size="1.25rem" stroke={1.5} />}
|
||||
title={t("management.page.importExport.export.warning.title")}
|
||||
>
|
||||
<List size="sm" spacing="xs">
|
||||
{exportWarningPointKeys.map((key) => (
|
||||
<List.Item key={key}>{t(key)}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Alert>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("management.page.importExport.summary.title")}
|
||||
</Text>
|
||||
{previewLoading && (
|
||||
<Group gap="xs">
|
||||
<Loader size="xs" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("management.page.importExport.preview.exportLoading")}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{preview && <ConfigSummaryList counts={preview} t={t} />}
|
||||
</Stack>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={closePopover}>
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
color="red.9"
|
||||
onClick={handleConfirmExport}
|
||||
disabled={previewLoading}
|
||||
loading={isPending}
|
||||
>
|
||||
{t("common.action.continue")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,126 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, Button, Card, FileInput, Group, List, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconCheck, IconFileUpload } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { ConfigImportPreviewPanel } from "~/components/config-import/config-import-preview-panel";
|
||||
|
||||
export const ImportConfigCard = () => {
|
||||
const t = useI18n() as unknown as (key: string, values?: Record<string, unknown>) => string;
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [fileContent, setFileContent] = useState("");
|
||||
const [importResult, setImportResult] = useState<{
|
||||
boards: number;
|
||||
apps: number;
|
||||
integrations: number;
|
||||
groups: number;
|
||||
searchEngines: number;
|
||||
warnings: string[];
|
||||
} | null>(null);
|
||||
|
||||
const {
|
||||
mutate: runPreview,
|
||||
data: preview,
|
||||
isPending: previewLoading,
|
||||
reset: resetPreview,
|
||||
} = clientApi.config.previewImport.useMutation();
|
||||
|
||||
const { mutateAsync, isPending } = clientApi.config.importFull.useMutation({
|
||||
async onSuccess() {
|
||||
await revalidatePathActionAsync("/manage");
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileContent) {
|
||||
resetPreview();
|
||||
return;
|
||||
}
|
||||
runPreview({ content: fileContent });
|
||||
}, [fileContent, runPreview, resetPreview]);
|
||||
|
||||
const importAllowed = preview?.compatibility.status === "compatible";
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file || !importAllowed) {
|
||||
return;
|
||||
}
|
||||
const content = await file.text();
|
||||
await mutateAsync(
|
||||
{ content },
|
||||
{
|
||||
onSuccess(result) {
|
||||
setImportResult(result);
|
||||
showSuccessNotification({
|
||||
title: t("management.page.importExport.importConfig.notification.success.title"),
|
||||
message: t("management.page.importExport.importConfig.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("management.page.importExport.importConfig.notification.error.title"),
|
||||
message: t("management.page.importExport.importConfig.notification.error.message"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card withBorder>
|
||||
<Stack>
|
||||
<Stack gap="xs">
|
||||
<Title order={3}>{t("management.page.importExport.importConfig.title")}</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("management.page.importExport.importConfig.description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<FileInput
|
||||
rightSection={<IconFileUpload />}
|
||||
accept="application/json"
|
||||
value={file}
|
||||
onChange={(newFile) => {
|
||||
setFile(newFile);
|
||||
setImportResult(null);
|
||||
setFileContent("");
|
||||
|
||||
if (!newFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
void newFile.text().then((content) => {
|
||||
setFileContent(content);
|
||||
});
|
||||
}}
|
||||
label={t("management.page.importExport.importConfig.fileLabel")}
|
||||
/>
|
||||
|
||||
<ConfigImportPreviewPanel preview={preview} previewLoading={previewLoading} t={t} />
|
||||
|
||||
{importResult && (
|
||||
<Alert color="green" icon={<IconCheck />}>
|
||||
<List size="sm">
|
||||
<List.Item>{t("management.page.importExport.importConfig.result.boardsImported", { count: importResult.boards })}</List.Item>
|
||||
<List.Item>{t("management.page.importExport.importConfig.result.appsCreated", { count: importResult.apps })}</List.Item>
|
||||
<List.Item>{t("management.page.importExport.importConfig.result.integrationsCreated", { count: importResult.integrations })}</List.Item>
|
||||
<List.Item>{t("management.page.importExport.importConfig.result.groupsCreated", { count: importResult.groups })}</List.Item>
|
||||
</List>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="end">
|
||||
<Button loading={isPending} disabled={!file || !importAllowed || previewLoading} onClick={() => void handleImport()}>
|
||||
{t("common.action.import")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,181 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button, Card, Code, FileInput, Group, Stack, Switch, Text, TextInput, Title } from "@mantine/core";
|
||||
import { IconFileUpload } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import {
|
||||
extractHomepageEnvVariables,
|
||||
parseServicesYaml,
|
||||
replaceHomepageEnvVariables,
|
||||
} from "@homarr/board-portability/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { boardNameSchema } from "@homarr/validation/board";
|
||||
|
||||
export const ImportHomepageCard = () => {
|
||||
const t = useI18n() as unknown as (key: string) => string;
|
||||
const [fileValid, setFileValid] = useState(true);
|
||||
const [rawContent, setRawContent] = useState("");
|
||||
const [envVars, setEnvVars] = useState<string[]>([]);
|
||||
const [envValues, setEnvValues] = useState<Record<string, string>>({});
|
||||
|
||||
const form = useZodForm(
|
||||
z.object({
|
||||
file: z.file(),
|
||||
boardName: boardNameSchema,
|
||||
createIntegrations: z.boolean(),
|
||||
}),
|
||||
{
|
||||
mode: "controlled",
|
||||
initialValues: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
file: null!,
|
||||
boardName: "",
|
||||
createIntegrations: true,
|
||||
},
|
||||
onValuesChange(values, previous) {
|
||||
void (async () => {
|
||||
if (values.file === previous.file) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!values.file) return;
|
||||
|
||||
const content = await values.file.text();
|
||||
setRawContent(content);
|
||||
|
||||
const vars = extractHomepageEnvVariables(content);
|
||||
setEnvVars(vars);
|
||||
setEnvValues(Object.fromEntries(vars.map((v: string) => [v, ""])));
|
||||
|
||||
const result = parseServicesYaml(content);
|
||||
if (!result.success) {
|
||||
setFileValid(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setFileValid(true);
|
||||
const suggestedName =
|
||||
result.services[0]?.group.replaceAll(/[^A-Za-z0-9-_]/g, "-").replaceAll(/-+/g, "-") ?? "homepage";
|
||||
if (form.values.boardName.trim().length === 0) {
|
||||
form.setFieldValue("boardName", suggestedName.slice(0, 255));
|
||||
}
|
||||
})();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync, isPending } = clientApi.board.importHomepageServices.useMutation({
|
||||
async onSuccess() {
|
||||
await revalidatePathActionAsync("/manage/boards");
|
||||
},
|
||||
});
|
||||
|
||||
const allEnvVarsFilled = envVars.length === 0 || envVars.every((v) => (envValues[v] ?? "").trim().length > 0);
|
||||
|
||||
return (
|
||||
<Card withBorder>
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
if (!fileValid || !allEnvVarsFilled) return;
|
||||
|
||||
void (async () => {
|
||||
const resolvedContent =
|
||||
envVars.length > 0 ? replaceHomepageEnvVariables(rawContent, envValues) : rawContent;
|
||||
|
||||
await mutateAsync(
|
||||
{
|
||||
content: resolvedContent,
|
||||
boardName: values.boardName,
|
||||
createIntegrations: values.createIntegrations,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
title: t("management.page.importExport.importHomepage.notification.success.title"),
|
||||
message: t("management.page.importExport.importHomepage.notification.success.message"),
|
||||
});
|
||||
form.reset();
|
||||
setRawContent("");
|
||||
setEnvVars([]);
|
||||
setEnvValues({});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("management.page.importExport.importHomepage.notification.error.title"),
|
||||
message: t("management.page.importExport.importHomepage.notification.error.message"),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
})();
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<Stack gap="xs">
|
||||
<Title order={3}>{t("management.page.importExport.importHomepage.title")}</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("management.page.importExport.importHomepage.description")}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<FileInput
|
||||
rightSection={<IconFileUpload />}
|
||||
withAsterisk
|
||||
accept=".yaml,.yml"
|
||||
{...form.getInputProps("file")}
|
||||
error={
|
||||
(form.getInputProps("file").error as string | undefined) ??
|
||||
(!fileValid && form.isDirty("file") ? t("management.page.importExport.importHomepage.invalidFile") : undefined)
|
||||
}
|
||||
type="button"
|
||||
label={t("management.page.importExport.importHomepage.fileLabel")}
|
||||
/>
|
||||
|
||||
{envVars.length > 0 && (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("management.page.importExport.importHomepage.envVariables.label")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("management.page.importExport.importHomepage.envVariables.description")}
|
||||
</Text>
|
||||
{envVars.map((varName) => (
|
||||
<TextInput
|
||||
key={varName}
|
||||
label={<Code>{`{{${varName}}}`}</Code>}
|
||||
placeholder={varName.replace("HOMEPAGE_VAR_", "").toLowerCase().replaceAll("_", ".")}
|
||||
value={envValues[varName] ?? ""}
|
||||
onChange={(event) => {
|
||||
setEnvValues((prev) => ({ ...prev, [varName]: event.target.value }));
|
||||
}}
|
||||
withAsterisk
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
withAsterisk
|
||||
label={t("management.page.importExport.importHomepage.boardNameLabel")}
|
||||
{...form.getInputProps("boardName")}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label={t("management.page.importExport.importHomepage.createIntegrations")}
|
||||
{...form.getInputProps("createIntegrations", { type: "checkbox" })}
|
||||
/>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" loading={isPending} disabled={!allEnvVarsFilled}>
|
||||
{t("common.action.import")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Stack, Title } from "@mantine/core";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { env } from "@homarr/docker/env";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { DockerDiscoveryPanel } from "./docker-discovery-panel";
|
||||
import { ExportConfigCard } from "./export-config-card";
|
||||
import { ImportConfigCard } from "./import-config-card";
|
||||
import { ImportHomepageCard } from "./import-homepage-card";
|
||||
|
||||
export default async function ImportExportPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("admin")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const t = await getScopedI18n("management.page.importExport" as never) as unknown as (key: string) => string;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{t("title")}</Title>
|
||||
<ExportConfigCard />
|
||||
<ImportConfigCard />
|
||||
<ImportHomepageCard />
|
||||
{env.ENABLE_DOCKER && <DockerDiscoveryPanel />}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -6,23 +6,10 @@ import { createContext, useContext, useMemo, useState } from "react";
|
||||
import type { LogLevel } 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<{
|
||||
level: LogLevel;
|
||||
setLevel: (level: LogLevel) => void;
|
||||
activeLevels: LogLevel[];
|
||||
fontSize: number;
|
||||
setFontSize: (size: number) => void;
|
||||
} | null>(null);
|
||||
|
||||
interface LogContextProviderProps extends PropsWithChildren {
|
||||
@@ -31,19 +18,9 @@ interface LogContextProviderProps extends PropsWithChildren {
|
||||
|
||||
export const LogContextProvider = ({ defaultLevel, children }: LogContextProviderProps) => {
|
||||
const [level, setLevel] = useState(defaultLevel);
|
||||
const [fontSize, setFontSizeRaw] = useState(readStoredFontSize);
|
||||
const activeLevels = useMemo(() => logLevels.slice(0, logLevels.indexOf(level) + 1), [level]);
|
||||
|
||||
const setFontSize = (size: number) => {
|
||||
setFontSizeRaw(size);
|
||||
localStorage.setItem(FONT_SIZE_KEY, String(size));
|
||||
};
|
||||
|
||||
return (
|
||||
<LogContext.Provider value={{ level, setLevel, activeLevels, fontSize, setFontSize }}>
|
||||
{children}
|
||||
</LogContext.Provider>
|
||||
);
|
||||
return <LogContext.Provider value={{ level, setLevel, activeLevels }}>{children}</LogContext.Provider>;
|
||||
};
|
||||
|
||||
export const useLogContext = () => {
|
||||
|
||||
@@ -13,7 +13,6 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { ClientSideTerminalComponent } from "./client";
|
||||
import { LogFontSizeSlider } from "./font-size-slider";
|
||||
import { LogLevelSelection } from "./level-selection";
|
||||
import { LogContextProvider } from "./log-context";
|
||||
|
||||
@@ -39,10 +38,7 @@ export default async function LogsManagementPage() {
|
||||
<LogContextProvider defaultLevel={logsEnv.LEVEL}>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<DynamicBreadcrumb />
|
||||
<Group gap="md" wrap="nowrap">
|
||||
<LogFontSizeSlider />
|
||||
<LogLevelSelection />
|
||||
</Group>
|
||||
<LogLevelSelection />
|
||||
</Group>
|
||||
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
|
||||
<ClientSideTerminalComponent />
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { 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";
|
||||
|
||||
@@ -12,11 +13,9 @@ import classes from "./terminal.module.css";
|
||||
|
||||
export const TerminalComponent = () => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { activeLevels, fontSize } = useLogContext();
|
||||
|
||||
const terminalRef = useRef<Terminal | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const { activeLevels } = useLogContext();
|
||||
|
||||
const terminalRef = useRef<Terminal>(null);
|
||||
clientApi.log.subscribe.useSubscription(
|
||||
{
|
||||
levels: activeLevels,
|
||||
@@ -27,6 +26,7 @@ export const TerminalComponent = () => {
|
||||
terminalRef.current?.refresh(0, terminalRef.current.rows - 1);
|
||||
},
|
||||
onError(err) {
|
||||
// This makes sense as logging might cause an infinite loop
|
||||
alert(err);
|
||||
},
|
||||
},
|
||||
@@ -37,39 +37,28 @@ export const TerminalComponent = () => {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
const terminal = new Terminal({
|
||||
const canvasAddon = new CanvasAddon();
|
||||
|
||||
terminalRef.current = new Terminal({
|
||||
cursorBlink: false,
|
||||
disableStdin: true,
|
||||
convertEol: true,
|
||||
fontSize,
|
||||
});
|
||||
terminal.open(ref.current);
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminalRef.current = terminal;
|
||||
fitAddonRef.current = fitAddon;
|
||||
terminalRef.current.open(ref.current);
|
||||
terminalRef.current.loadAddon(canvasAddon);
|
||||
|
||||
const fitTerminal = () => fitAddon.fit();
|
||||
const fitTimeout = setTimeout(fitTerminal);
|
||||
|
||||
const resizeObserver = new ResizeObserver(fitTerminal);
|
||||
resizeObserver.observe(ref.current);
|
||||
// This is a hack to make sure the terminal is rendered before we try to fit it
|
||||
// You can blame @Meierschlumpf for this
|
||||
setTimeout(() => {
|
||||
const fitAddon = new FitAddon();
|
||||
terminalRef.current?.loadAddon(fitAddon);
|
||||
fitAddon.fit();
|
||||
});
|
||||
|
||||
return () => {
|
||||
clearTimeout(fitTimeout);
|
||||
resizeObserver.disconnect();
|
||||
fitAddon.dispose();
|
||||
terminal.dispose();
|
||||
terminalRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
terminalRef.current?.dispose();
|
||||
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>;
|
||||
};
|
||||
-53
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -13,7 +13,6 @@ import { canAccessUserEditPage } from "../access";
|
||||
import { DeleteUserButton } from "./_components/_delete-user-button";
|
||||
import { UserGeneralSettingsForm } from "./_components/_general-settings-form";
|
||||
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
|
||||
import { ResetTours } from "./_components/_reset-tours";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{
|
||||
@@ -88,13 +87,6 @@ export default async function EditUserPage(props: Props) {
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
{session?.user.id === user.id && (
|
||||
<Stack mb="lg">
|
||||
<Title order={2}>{tGeneral("item.onboardingTours.title")}</Title>
|
||||
<ResetTours />
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<DangerZoneRoot>
|
||||
<DangerZoneItem
|
||||
label={t("user.action.delete.label")}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { UserAvatar } from "@homarr/ui";
|
||||
|
||||
import { ManageContainer } from "~/components/manage/manage-container";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { catchTrpcNotFound, catchTrpcUnauthorized } from "~/errors/trpc-catch-error";
|
||||
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||
import { NavigationLink } from "../groups/[id]/_navigation";
|
||||
import { canAccessUserEditPage } from "./access";
|
||||
|
||||
@@ -26,7 +26,7 @@ export default async function Layout(props: PropsWithChildren<LayoutProps>) {
|
||||
const session = await auth();
|
||||
const t = await getI18n();
|
||||
const tUser = await getScopedI18n("management.page.user");
|
||||
const user = await api.user.getById({ userId: params.userId }).catch(catchTrpcNotFound).catch(catchTrpcUnauthorized);
|
||||
const user = await api.user.getById({ userId: params.userId }).catch(catchTrpcNotFound);
|
||||
|
||||
if (!canAccessUserEditPage(session, user.id)) {
|
||||
notFound();
|
||||
|
||||
@@ -5,7 +5,7 @@ import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { catchTrpcNotFound, catchTrpcUnauthorized } from "~/errors/trpc-catch-error";
|
||||
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||
import { canAccessUserEditPage } from "../access";
|
||||
import { ChangePasswordForm } from "./_components/_change-password-form";
|
||||
|
||||
@@ -23,8 +23,7 @@ export default async function UserSecurityPage(props: Props) {
|
||||
.getById({
|
||||
userId: params.userId,
|
||||
})
|
||||
.catch(catchTrpcNotFound)
|
||||
.catch(catchTrpcUnauthorized);
|
||||
.catch(catchTrpcNotFound);
|
||||
|
||||
if (!canAccessUserEditPage(session, user.id)) {
|
||||
notFound();
|
||||
|
||||
+26
-6
@@ -1,7 +1,20 @@
|
||||
"use client";
|
||||
|
||||
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 { IconPlus, IconUserCheck } from "@tabler/icons-react";
|
||||
import { z } from "zod/v4";
|
||||
@@ -11,11 +24,10 @@ import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
import { everyoneGroup, groupPermissions } from "@homarr/definitions";
|
||||
import type { IsValid } from "@homarr/form";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { UserCreatePasswordFields } from "@homarr/forms-collection";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
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 { userPasswordSchema } from "@homarr/validation/user";
|
||||
|
||||
@@ -155,10 +167,18 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
|
||||
<form>
|
||||
<Card p="xl">
|
||||
<Stack gap="md">
|
||||
<UserCreatePasswordFields
|
||||
<CustomPasswordInput
|
||||
withPasswordRequirements
|
||||
label={tUserField("password.label")}
|
||||
variant="filled"
|
||||
passwordInputProps={securityForm.getInputProps("password")}
|
||||
confirmPasswordInputProps={securityForm.getInputProps("confirmPassword")}
|
||||
withAsterisk
|
||||
{...securityForm.getInputProps("password")}
|
||||
/>
|
||||
<PasswordInput
|
||||
label={tUserField("passwordConfirm.label")}
|
||||
variant="filled"
|
||||
withAsterisk
|
||||
{...securityForm.getInputProps("confirmPassword")}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { TourTarget } from "~/components/layout/header/tour-target";
|
||||
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
@@ -39,18 +38,14 @@ export default async function UsersPage() {
|
||||
title={t("list.title")}
|
||||
primaryAction={
|
||||
credentialsProviderEnabled ? (
|
||||
<TourTarget id="manage-users-create">
|
||||
<MobileAffixButton component={Link} href="/manage/users/create">
|
||||
{t("create.title")}
|
||||
</MobileAffixButton>
|
||||
</TourTarget>
|
||||
<MobileAffixButton component={Link} href="/manage/users/create">
|
||||
{t("create.title")}
|
||||
</MobileAffixButton>
|
||||
) : undefined
|
||||
}
|
||||
floatingPrimaryAction={credentialsProviderEnabled}
|
||||
>
|
||||
<TourTarget id="manage-users-list">
|
||||
<UserListComponent initialUserList={userList} />
|
||||
</TourTarget>
|
||||
<UserListComponent initialUserList={userList} />
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
@@ -85,7 +85,6 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
|
||||
),
|
||||
integrationSupport: "supportedIntegrations" in currentDefinition,
|
||||
settings,
|
||||
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
|
||||
},
|
||||
{
|
||||
title(t) {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
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 { useZodForm } from "@homarr/form";
|
||||
import type { GridStack } from "@homarr/gridstack";
|
||||
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { Item, SectionItem } from "~/app/[locale]/boards/_types";
|
||||
|
||||
@@ -16,7 +16,9 @@ interface InnerProps {
|
||||
}
|
||||
|
||||
export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
||||
const tCommon = useScopedI18n("common");
|
||||
const t = useI18n();
|
||||
// Keep track of the maximum width based on the x offset
|
||||
const maxWidthRef = useRef(innerProps.columnCount - innerProps.item.xOffset);
|
||||
const form = useZodForm(
|
||||
z.object({
|
||||
@@ -36,6 +38,7 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
|
||||
height: innerProps.item.height,
|
||||
},
|
||||
onValuesChange(values, previous) {
|
||||
// Update the maximum width when the x offset changes
|
||||
if (values.xOffset !== previous.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 }}>
|
||||
<NumberInput
|
||||
label={t("item.moveResize.field.xOffset.label")}
|
||||
data-autofocus
|
||||
min={0}
|
||||
max={innerProps.columnCount - 1}
|
||||
{...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")} />
|
||||
</Grid.Col>
|
||||
</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>
|
||||
</form>
|
||||
);
|
||||
@@ -99,5 +106,5 @@ export const ItemMoveModal = createModal<InnerProps>(({ actions, innerProps }) =
|
||||
defaultTitle(t) {
|
||||
return t("item.moveResize.title");
|
||||
},
|
||||
size: modalSizeForm,
|
||||
size: "lg",
|
||||
});
|
||||
@@ -1,14 +1,14 @@
|
||||
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 { createId, objectEntries } from "@homarr/common";
|
||||
import { getIconUrl, getIntegrationName } from "@homarr/definitions";
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
import { WidgetEditModal } from "@homarr/widgets/modals";
|
||||
@@ -86,31 +86,32 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<SelectGridLayout
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder={`${t("item.create.search")}...`}
|
||||
onSearchKeyDown={(event) => {
|
||||
if (event.key === "Enter" && filteredItems.length === 1) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
handleAdd(filteredItems[0]!.kind);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{filteredItems.map((item) => (
|
||||
<WidgetItem key={item.kind} item={item} onSelect={() => handleAdd(item.kind)} />
|
||||
))}
|
||||
<Stack>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
leftSection={<IconSearch />}
|
||||
placeholder={`${t("item.create.search")}...`}
|
||||
data-autofocus
|
||||
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
|
||||
handleAdd(filteredItems[0]!.kind);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{filteredItems.length === 0 && (
|
||||
<Center p="xl">
|
||||
<Text c="dimmed">{t("common.noResults")}</Text>
|
||||
</Center>
|
||||
)}
|
||||
</SelectGridLayout>
|
||||
<Grid>
|
||||
{filteredItems.map((item) => (
|
||||
<WidgetItem key={item.kind} item={item} onSelect={() => handleAdd(item.kind)} />
|
||||
))}
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle: (t) => t("item.create.title"),
|
||||
size: modalSizeSelect,
|
||||
size: "xl",
|
||||
});
|
||||
|
||||
const WidgetItem = ({
|
||||
@@ -129,58 +130,46 @@ const WidgetItem = ({
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Card
|
||||
h={selectGridCardHeight}
|
||||
withBorder
|
||||
pos="relative"
|
||||
style={{ overflow: "hidden", "--_hover-opacity": "0" }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
|
||||
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<item.icon size={22} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
|
||||
{item.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Tooltip label={item.description} multiline w={250} disabled={!item.description}>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
|
||||
{item.description}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<SupportedIntegrations integrations={item.supportedIntegrations} />
|
||||
</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>
|
||||
<Grid.Col span={{ xs: 12, sm: 4, md: 3 }}>
|
||||
<Card h="100%">
|
||||
<Stack justify="space-between" h="100%">
|
||||
<Stack gap="xs">
|
||||
<Center>
|
||||
<item.icon />
|
||||
</Center>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} ta="center">
|
||||
{item.name}
|
||||
</Text>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" ta="center" c="dimmed">
|
||||
{item.description}
|
||||
</Text>
|
||||
</Stack>
|
||||
<SupportedIntegrations mt="auto" integrations={item.supportedIntegrations} />
|
||||
<Button onClick={onSelect} variant="light" size="xs" fullWidth>
|
||||
{t(`item.create.addToBoard`)}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
);
|
||||
};
|
||||
|
||||
const SupportedIntegrations = ({ integrations }: { integrations: IntegrationKind[] }) => {
|
||||
interface SupportedIntegrationsProps {
|
||||
integrations: IntegrationKind[];
|
||||
mt: string;
|
||||
}
|
||||
|
||||
const SupportedIntegrations = ({ integrations, mt }: SupportedIntegrationsProps) => {
|
||||
if (integrations.length === 0) {
|
||||
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 moreCount = integrations.length - countToShow;
|
||||
|
||||
return (
|
||||
<Group gap={2} mt="auto">
|
||||
<Center mt={mt}>
|
||||
<Tooltip.Group closeDelay={100}>
|
||||
<Group gap={2}>
|
||||
{integrations.slice(0, countToShow).map((integration) => (
|
||||
@@ -206,6 +195,6 @@ const SupportedIntegrations = ({ integrations }: { integrations: IntegrationKind
|
||||
)}
|
||||
</Group>
|
||||
</Tooltip.Group>
|
||||
</Group>
|
||||
</Center>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Stack, TextInput } from "@mantine/core";
|
||||
import { Button, Group, Stack, TextInput } from "@mantine/core";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface Category {
|
||||
@@ -36,10 +36,13 @@ export const CategoryEditModal = createModal<InnerProps>(({ actions, innerProps
|
||||
>
|
||||
<Stack>
|
||||
<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>
|
||||
</form>
|
||||
);
|
||||
}).withOptions({
|
||||
size: modalSizeForm,
|
||||
});
|
||||
}).withOptions({});
|
||||
@@ -1,10 +1,10 @@
|
||||
"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 { useZodForm } from "@homarr/form";
|
||||
import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { TextMultiSelect } from "@homarr/ui";
|
||||
import { dynamicSectionOptionsSchema } from "@homarr/validation/shared";
|
||||
@@ -31,7 +31,7 @@ export const DynamicSectionEditModal = createModal<ModalProps>(({ actions, inner
|
||||
})}
|
||||
>
|
||||
<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
|
||||
label={t("section.dynamic.option.customCssClasses.label")}
|
||||
{...form.getInputProps("customCssClasses")}
|
||||
@@ -48,7 +48,14 @@ export const DynamicSectionEditModal = createModal<ModalProps>(({ actions, inner
|
||||
}
|
||||
{...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>
|
||||
</form>
|
||||
);
|
||||
@@ -56,5 +63,5 @@ export const DynamicSectionEditModal = createModal<ModalProps>(({ actions, inner
|
||||
defaultTitle(t) {
|
||||
return t("item.edit.title");
|
||||
},
|
||||
size: modalSizeForm,
|
||||
size: "lg",
|
||||
});
|
||||
@@ -1,138 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, Group, List, Loader, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import type { ConfigImportPreview } from "@homarr/board-portability";
|
||||
|
||||
import { ConfigSummaryList } from "./config-summary-list";
|
||||
|
||||
const compatibilityAlertColor: Record<ConfigImportPreview["compatibility"]["status"], string> = {
|
||||
compatible: "blue",
|
||||
unsupportedVersion: "red",
|
||||
invalidStructure: "red",
|
||||
};
|
||||
|
||||
type CountSection = { labelKey: string; entries: { key: string; count: number }[] };
|
||||
|
||||
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
||||
|
||||
const buildCountSections = (preview: ConfigImportPreview): CountSection[] => [
|
||||
{
|
||||
labelKey: "management.page.importExport.preview.importWillReuse",
|
||||
entries: Object.entries(preview.toReuse)
|
||||
.filter(([, count]) => count > 0)
|
||||
.map(([key, count]) => ({ key: `management.page.importExport.preview.reuse${capitalize(key)}`, count })),
|
||||
},
|
||||
{
|
||||
labelKey: "management.page.importExport.preview.importWillSkip",
|
||||
entries: Object.entries(preview.toSkip)
|
||||
.filter(([, count]) => count > 0)
|
||||
.map(([key, count]) => ({ key: `management.page.importExport.preview.skip${capitalize(key)}`, count })),
|
||||
},
|
||||
{
|
||||
labelKey: "management.page.importExport.preview.importWillUpdate",
|
||||
entries: Object.entries(preview.toUpdate)
|
||||
.filter(([, count]) => count > 0)
|
||||
.map(([key, count]) => ({ key: `management.page.importExport.preview.update${capitalize(key)}`, count })),
|
||||
},
|
||||
];
|
||||
|
||||
type ConfigImportPreviewPanelProps = {
|
||||
preview: ConfigImportPreview | undefined;
|
||||
previewLoading: boolean;
|
||||
t: (key: string, values?: Record<string, unknown>) => string;
|
||||
};
|
||||
|
||||
export const ConfigImportPreviewPanel = ({ preview, previewLoading, t }: ConfigImportPreviewPanelProps) => {
|
||||
if (!preview && !previewLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const importAllowed = preview?.compatibility.status === "compatible";
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{previewLoading && (
|
||||
<Group gap="xs">
|
||||
<Loader size="xs" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("management.page.importExport.preview.importLoading")}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<>
|
||||
<Alert
|
||||
variant="light"
|
||||
color={compatibilityAlertColor[preview.compatibility.status]}
|
||||
icon={<IconAlertTriangle size="1rem" stroke={1.5} />}
|
||||
title={
|
||||
preview.compatibility.status === "compatible"
|
||||
? t("management.page.importExport.preview.migrationNote", {
|
||||
bundleVersion: preview.meta.bundleVersion || "?",
|
||||
})
|
||||
: t("management.page.importExport.preview.importIncompatible")
|
||||
}
|
||||
>
|
||||
{preview.compatibility.status === "compatible" && preview.meta.exportedAt && (
|
||||
<Text size="sm" mb="xs">
|
||||
{t("management.page.importExport.preview.importMeta", {
|
||||
exportedAt: preview.meta.exportedAt,
|
||||
homarrVersion: preview.meta.homarrVersion,
|
||||
bundleVersion: preview.meta.bundleVersion,
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
{preview.compatibility.issues.length > 0 && (
|
||||
<List size="sm" spacing={4}>
|
||||
{preview.compatibility.issues.map((issue) => (
|
||||
<List.Item key={issue}>{issue}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
{importAllowed && (
|
||||
<>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("management.page.importExport.preview.importWillCreate")}
|
||||
</Text>
|
||||
<ConfigSummaryList counts={preview.toCreate} t={t} />
|
||||
</Stack>
|
||||
|
||||
{buildCountSections(preview)
|
||||
.filter((section) => section.entries.length > 0)
|
||||
.map((section) => (
|
||||
<Stack gap="xs" key={section.labelKey}>
|
||||
<Text size="sm" fw={500}>
|
||||
{t(section.labelKey)}
|
||||
</Text>
|
||||
<List size="sm">
|
||||
{section.entries.map((entry) => (
|
||||
<List.Item key={entry.key}>
|
||||
{t(entry.key, { count: entry.count })}
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Stack>
|
||||
))}
|
||||
|
||||
{preview.warnings.length > 0 && (
|
||||
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size="1rem" />}>
|
||||
<List size="sm">
|
||||
{preview.warnings.map((warning) => (
|
||||
<List.Item key={warning}>{warning}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -1,35 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { List, Text } from "@mantine/core";
|
||||
|
||||
import type { ConfigEntityCounts, ConfigEntityKey } from "@homarr/board-portability";
|
||||
import { CONFIG_ENTITY_KEYS } from "@homarr/board-portability";
|
||||
|
||||
const TRANSLATION_PREFIX = "management.page.importExport.summary";
|
||||
|
||||
type ConfigSummaryListProps = {
|
||||
counts: ConfigEntityCounts;
|
||||
t: (key: string, values?: Record<string, unknown>) => string;
|
||||
};
|
||||
|
||||
export const ConfigSummaryList = ({ counts, t }: ConfigSummaryListProps) => {
|
||||
const entries = CONFIG_ENTITY_KEYS.filter((key) => counts[key] > 0);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(`${TRANSLATION_PREFIX}.empty`)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<List size="sm" spacing={4}>
|
||||
{entries.map((key: ConfigEntityKey) => (
|
||||
<List.Item key={key}>
|
||||
{t(`${TRANSLATION_PREFIX}.${key}`, { count: counts[key] })}
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
@@ -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()));
|
||||
@@ -1,140 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Badge, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { IconBrandDocker, IconCheck, IconPuzzle } from "@tabler/icons-react";
|
||||
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { IntegrationAvatar, SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
|
||||
|
||||
import { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared";
|
||||
|
||||
interface IntegrationMultiSelectGridProps {
|
||||
selectedKinds: IntegrationKind[];
|
||||
onSelectionChange: (kinds: IntegrationKind[]) => void;
|
||||
enableMockIntegration?: boolean;
|
||||
onboarding?: boolean;
|
||||
detectedKinds?: Set<IntegrationKind>;
|
||||
}
|
||||
|
||||
export const IntegrationMultiSelectGrid = ({
|
||||
selectedKinds: selectedKindsArray,
|
||||
onSelectionChange,
|
||||
enableMockIntegration = false,
|
||||
onboarding = false,
|
||||
detectedKinds,
|
||||
}: IntegrationMultiSelectGridProps) => {
|
||||
const [search, setSearch] = useState("");
|
||||
const selectedKinds = useMemo(() => new Set(selectedKindsArray), [selectedKindsArray]);
|
||||
const t = useI18n();
|
||||
|
||||
const toggleKind = useCallback(
|
||||
(kind: IntegrationKind) => {
|
||||
const current = new Set(selectedKindsArray);
|
||||
if (current.has(kind)) {
|
||||
current.delete(kind);
|
||||
} else {
|
||||
current.add(kind);
|
||||
}
|
||||
onSelectionChange(Array.from(current));
|
||||
},
|
||||
[selectedKindsArray, onSelectionChange],
|
||||
);
|
||||
|
||||
const integrations = useMemo(
|
||||
() => buildSortedIntegrations({ enableMockIntegration, onboarding }),
|
||||
[enableMockIntegration, onboarding],
|
||||
);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (!detectedKinds || detectedKinds.size === 0) return integrations;
|
||||
const detected = integrations.filter((i) => detectedKinds.has(i.kind));
|
||||
const rest = integrations.filter((i) => !detectedKinds.has(i.kind));
|
||||
return [...detected, ...rest];
|
||||
}, [integrations, detectedKinds]);
|
||||
|
||||
const filtered = useMemo(() => filterIntegrations(sorted, search), [sorted, search]);
|
||||
|
||||
return (
|
||||
<SelectGridLayout
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder={`${t("integration.page.list.search")}...`}
|
||||
disableScroll={onboarding}
|
||||
disableAutoFocus={onboarding}
|
||||
>
|
||||
{filtered.map((integration) => {
|
||||
const isSelected = selectedKinds.has(integration.kind);
|
||||
const isDetected = detectedKinds?.has(integration.kind) ?? false;
|
||||
return (
|
||||
<Card
|
||||
key={integration.kind}
|
||||
h={selectGridCardHeight}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
borderColor: isSelected ? "var(--mantine-color-blue-6)" : undefined,
|
||||
borderWidth: isSelected ? 2 : undefined,
|
||||
}}
|
||||
onClick={() => toggleKind(integration.kind)}
|
||||
withBorder
|
||||
>
|
||||
<Stack justify="space-between" h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<IntegrationAvatar kind={integration.kind} size="sm" />
|
||||
<Text fw={500} lh={1.2} size="sm" lineClamp={2} style={{ flex: 1 }}>
|
||||
{integration.name}
|
||||
</Text>
|
||||
{isSelected && <IconCheck size={18} color="var(--mantine-color-blue-6)" style={{ flexShrink: 0 }} />}
|
||||
</Group>
|
||||
|
||||
<Stack gap={4} mt="auto">
|
||||
{isDetected && (
|
||||
<Badge variant="light" color="teal" size="xs" leftSection={<IconBrandDocker size={12} />}>
|
||||
{t("integration.grid.detected")}
|
||||
</Badge>
|
||||
)}
|
||||
<Group gap={4} wrap="wrap">
|
||||
{integration.categories.slice(0, 2).map((cat) => (
|
||||
<Badge key={cat} variant="light" size="xs">
|
||||
{categoryTranslationKeys[cat] ? t(categoryTranslationKeys[cat] as never) : cat}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
{integration.widgets.length > 0 ? (
|
||||
<Tooltip
|
||||
multiline
|
||||
w={200}
|
||||
label={
|
||||
<Stack gap={4}>
|
||||
{integration.widgets.map((widgetKind) => (
|
||||
<Text key={widgetKind} size="xs">
|
||||
{t(`widget.${widgetKind}.name`)}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
<Badge variant="light" color="blue" size="sm" leftSection={<IconPuzzle size={12} />}>
|
||||
{t("integration.grid.widgetCount", { count: String(integration.widgets.length) })}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
{t("integration.grid.noWidgets")}
|
||||
</Badge>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<Center p="xl">
|
||||
<Text c="dimmed">{t("common.noResults")}</Text>
|
||||
</Center>
|
||||
)}
|
||||
</SelectGridLayout>
|
||||
);
|
||||
};
|
||||
@@ -1,90 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Badge, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { IconPuzzle } from "@tabler/icons-react";
|
||||
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { IntegrationAvatar, SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
|
||||
|
||||
import { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared";
|
||||
|
||||
interface IntegrationSelectGridProps {
|
||||
onSelect: (kind: IntegrationKind) => void;
|
||||
enableMockIntegration?: boolean;
|
||||
}
|
||||
|
||||
export const IntegrationSelectGrid = ({ onSelect, enableMockIntegration = false }: IntegrationSelectGridProps) => {
|
||||
const [search, setSearch] = useState("");
|
||||
const t = useI18n();
|
||||
|
||||
const integrations = useMemo(() => buildSortedIntegrations({ enableMockIntegration }), [enableMockIntegration]);
|
||||
const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]);
|
||||
|
||||
return (
|
||||
<SelectGridLayout
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder={`${t("integration.page.list.search")}...`}
|
||||
>
|
||||
{filtered.map((integration) => (
|
||||
<Card
|
||||
key={integration.kind}
|
||||
h={selectGridCardHeight}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => onSelect(integration.kind)}
|
||||
withBorder
|
||||
>
|
||||
<Stack justify="space-between" h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<IntegrationAvatar kind={integration.kind} size="sm" />
|
||||
<Text fw={500} lh={1.2} size="sm" lineClamp={2}>
|
||||
{integration.name}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Stack gap={4} mt="auto">
|
||||
<Group gap={4} wrap="wrap">
|
||||
{integration.categories.slice(0, 2).map((cat) => (
|
||||
<Badge key={cat} variant="light" size="xs">
|
||||
{categoryTranslationKeys[cat] ? t(categoryTranslationKeys[cat] as never) : cat}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
{integration.widgets.length > 0 ? (
|
||||
<Tooltip
|
||||
multiline
|
||||
w={200}
|
||||
label={
|
||||
<Stack gap={4}>
|
||||
{integration.widgets.map((widgetKind) => (
|
||||
<Text key={widgetKind} size="xs">
|
||||
{t(`widget.${widgetKind}.name`)}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
<Badge variant="light" color="blue" size="sm" leftSection={<IconPuzzle size={12} />}>
|
||||
{t("integration.grid.widgetCount", { count: String(integration.widgets.length) })}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
{t("integration.grid.noWidgets")}
|
||||
</Badge>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<Center p="xl">
|
||||
<Text c="dimmed">{t("common.noResults")}</Text>
|
||||
</Center>
|
||||
)}
|
||||
</SelectGridLayout>
|
||||
);
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Group, ScrollArea, Title } from "@mantine/core";
|
||||
import { IconArrowLeft } from "@tabler/icons-react";
|
||||
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { createModal, modalSizeSelect } from "@homarr/modals";
|
||||
import { IntegrationAvatar } from "@homarr/ui";
|
||||
|
||||
import { NewIntegrationForm } from "~/app/[locale]/manage/integrations/new/_integration-new-form";
|
||||
import { IntegrationSelectGrid } from "./integration-select-grid";
|
||||
|
||||
interface IntegrationSelectModalProps {
|
||||
onSuccess?: () => void;
|
||||
enableMockIntegration?: boolean;
|
||||
}
|
||||
|
||||
export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>(({ actions, innerProps }) => {
|
||||
const [step, setStep] = useState<"select" | "form">("select");
|
||||
const [selectedKind, setSelectedKind] = useState<IntegrationKind | null>(null);
|
||||
|
||||
const handleSelect = (kind: IntegrationKind) => {
|
||||
setSelectedKind(kind);
|
||||
setStep("form");
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setStep("select");
|
||||
setSelectedKind(null);
|
||||
};
|
||||
|
||||
const handleSuccess = () => {
|
||||
innerProps.onSuccess?.();
|
||||
actions.closeModal();
|
||||
};
|
||||
|
||||
if (step === "form" && selectedKind) {
|
||||
return (
|
||||
<ScrollArea.Autosize mah="80vh">
|
||||
<Group gap="xs" mb="md" style={{ cursor: "pointer" }} onClick={handleBack}>
|
||||
<IconArrowLeft size={18} />
|
||||
<IntegrationAvatar kind={selectedKind} size="sm" />
|
||||
<Title order={4}>{getIntegrationName(selectedKind)}</Title>
|
||||
</Group>
|
||||
<NewIntegrationForm kind={selectedKind} onSuccess={handleSuccess} onCancel={handleBack} />
|
||||
</ScrollArea.Autosize>
|
||||
);
|
||||
}
|
||||
|
||||
return <IntegrationSelectGrid onSelect={handleSelect} enableMockIntegration={innerProps.enableMockIntegration} />;
|
||||
}).withOptions({
|
||||
defaultTitle: (t) => t("integration.action.create"),
|
||||
size: modalSizeSelect,
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import { Link } from "@homarr/ui";
|
||||
|
||||
import { ClientBurger } from "./header/burger";
|
||||
import { DesktopSearchInput, MobileSearchButton } from "./header/search";
|
||||
import { TourTarget } from "./header/tour-target";
|
||||
import { UserButton } from "./header/user";
|
||||
import { HomarrLogoWithTitle } from "./logo/homarr-logo";
|
||||
|
||||
@@ -26,15 +25,11 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
|
||||
{logo ?? <HomarrLogoWithTitle size="md" />}
|
||||
</UnstyledButton>
|
||||
</Group>
|
||||
<TourTarget id="board-search">
|
||||
<DesktopSearchInput />
|
||||
</TourTarget>
|
||||
<DesktopSearchInput />
|
||||
<Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap">
|
||||
{actions}
|
||||
<MobileSearchButton />
|
||||
<TourTarget id="board-user-menu">
|
||||
<UserButton />
|
||||
</TourTarget>
|
||||
<UserButton />
|
||||
</Group>
|
||||
</Group>
|
||||
<Spotlight />
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
||||
import { Box } from "@mantine/core";
|
||||
|
||||
export const TourTarget = ({ id, children }: { id: string; children: ReactNode }) => (
|
||||
<OnboardingTour.Target id={id}>
|
||||
<Box data-tour-target={id}>{children}</Box>
|
||||
</OnboardingTour.Target>
|
||||
);
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
||||
import { NavLink } from "@mantine/core";
|
||||
|
||||
import { Link } from "@homarr/ui";
|
||||
@@ -10,15 +9,9 @@ import { Link } from "@homarr/ui";
|
||||
export const CommonNavLink = (props: ClientNavigationLink) =>
|
||||
"href" in props ? <NavLinkHref {...props} /> : <NavLinkWithItems {...props} />;
|
||||
|
||||
const TourTarget = ({ id, children }: { id?: string; children: ReactNode }) => {
|
||||
if (!id) return <>{children}</>;
|
||||
return <OnboardingTour.Target id={id}>{children}</OnboardingTour.Target>;
|
||||
};
|
||||
|
||||
const NavLinkHref = (props: NavigationLinkHref) => {
|
||||
const pathname = usePathname();
|
||||
const tourId = props["data-onboarding-tour-id"];
|
||||
const link = props.external ? (
|
||||
return props.external ? (
|
||||
<NavLink component="a" label={props.label} leftSection={props.icon} href={props.href} target="_blank" />
|
||||
) : (
|
||||
<NavLink
|
||||
@@ -26,29 +19,26 @@ const NavLinkHref = (props: NavigationLinkHref) => {
|
||||
label={props.label}
|
||||
leftSection={props.icon}
|
||||
href={props.href}
|
||||
active={pathname === props.href || pathname.startsWith(`${props.href}/`)}
|
||||
active={pathname === props.href}
|
||||
/>
|
||||
);
|
||||
return <TourTarget id={tourId}>{link}</TourTarget>;
|
||||
};
|
||||
|
||||
const NavLinkWithItems = (props: NavigationLinkWithItems) => {
|
||||
const pathname = usePathname();
|
||||
const isActive = props.items.some((item) => pathname === item.href || pathname.startsWith(`${item.href}/`));
|
||||
const nav = (
|
||||
const isActive = props.items.some((item) => item.href === pathname);
|
||||
return (
|
||||
<NavLink label={props.label} leftSection={props.icon} defaultOpened={isActive}>
|
||||
{props.items.map((item) => (
|
||||
<NavLinkHref key={item.label} {...item} />
|
||||
))}
|
||||
</NavLink>
|
||||
);
|
||||
return <TourTarget id={props["data-onboarding-tour-id"]}>{nav}</TourTarget>;
|
||||
};
|
||||
|
||||
interface CommonNavigationLinkProps {
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
"data-onboarding-tour-id"?: string;
|
||||
}
|
||||
|
||||
interface NavigationLinkHref extends CommonNavigationLinkProps {
|
||||
|
||||
@@ -27,7 +27,7 @@ export const MainNavigation = ({ headerSection, footerSection, links }: MainNavi
|
||||
return null;
|
||||
}
|
||||
|
||||
const { icon: TablerIcon, iconProps, "data-onboarding-tour-id": tourId, ...props } = link;
|
||||
const { icon: TablerIcon, iconProps, ...props } = link;
|
||||
const Icon =
|
||||
typeof TablerIcon === "string" ? (
|
||||
<Image src={TablerIcon} w={20} h={20} />
|
||||
@@ -38,20 +38,17 @@ export const MainNavigation = ({ headerSection, footerSection, links }: MainNavi
|
||||
if ("items" in props) {
|
||||
clientLink = {
|
||||
...props,
|
||||
"data-onboarding-tour-id": tourId,
|
||||
items: props.items
|
||||
.filter((item) => !item.hidden)
|
||||
.map((item) => {
|
||||
const { "data-onboarding-tour-id": itemTourId, ...rest } = item;
|
||||
return {
|
||||
...rest,
|
||||
"data-onboarding-tour-id": itemTourId,
|
||||
...item,
|
||||
icon: <item.icon size={20} stroke={1.5} {...iconProps} />,
|
||||
};
|
||||
}),
|
||||
} as ClientNavigationLink;
|
||||
} else {
|
||||
clientLink = { ...props, "data-onboarding-tour-id": tourId } as ClientNavigationLink;
|
||||
clientLink = props as ClientNavigationLink;
|
||||
}
|
||||
return <CommonNavLink key={index} {...clientLink} icon={Icon} />;
|
||||
})}
|
||||
@@ -66,7 +63,6 @@ interface CommonNavigationLinkProps {
|
||||
icon: TablerIcon | string;
|
||||
iconProps?: TablerIconProps;
|
||||
hidden?: boolean;
|
||||
"data-onboarding-tour-id"?: string;
|
||||
}
|
||||
|
||||
interface NavigationLinkHref extends CommonNavigationLinkProps {
|
||||
|
||||
@@ -58,23 +58,19 @@ export const DynamicBreadcrumb = ({
|
||||
}
|
||||
const href = `/${pathnameParts.slice(0, index + 1).join("/")}`;
|
||||
const translationKey = `${pathnameParts.slice(0, index + 1).join(".")}`;
|
||||
const mappedValue = dynamicMappings?.get(pathnamePart);
|
||||
|
||||
if (mappedValue) {
|
||||
if (nonInteractable?.includes(pathnamePart)) {
|
||||
return <Text key={href}>{mappedValue}</Text>;
|
||||
}
|
||||
return (
|
||||
<Anchor key={href} href={href}>
|
||||
{mappedValue}
|
||||
</Anchor>
|
||||
);
|
||||
}
|
||||
|
||||
if (nonInteractable?.includes(pathnamePart)) {
|
||||
return <Text key={href}>{t(`${translationKey}.label` as TranslationKeys)}</Text>;
|
||||
}
|
||||
|
||||
if (dynamicMappings?.has(pathnamePart)) {
|
||||
return (
|
||||
<Anchor key={href} href={href}>
|
||||
{dynamicMappings.get(pathnamePart)}
|
||||
</Anchor>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Anchor key={href} href={href}>
|
||||
{t(`${translationKey}.label` as TranslationKeys)}
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useMemo } from "react";
|
||||
import type { OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { createDocumentationLink } from "@homarr/definitions";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useBoardPermissions } from "~/components/board/permissions/client";
|
||||
import { TourShell } from "./tour-shell";
|
||||
import { TourStepContent } from "./tour-step-content";
|
||||
|
||||
export const BoardTourProvider = ({ children }: PropsWithChildren) => {
|
||||
const t = useScopedI18n("onboardingTour.board");
|
||||
const board = useRequiredBoard();
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
|
||||
const { mutate: completeTour } = clientApi.user.completeTour.useMutation({
|
||||
onSuccess() {
|
||||
void utils.user.getTourStatus.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const started = tourStatus !== undefined && !tourStatus.completedBoardTour;
|
||||
|
||||
const handleEnd = () => {
|
||||
utils.user.getTourStatus.setData(undefined, {
|
||||
completedManageTour: tourStatus?.completedManageTour ?? false,
|
||||
completedBoardTour: true,
|
||||
});
|
||||
completeTour({ tour: "board" });
|
||||
};
|
||||
|
||||
const steps = useMemo(() => {
|
||||
const allSteps: (OnboardingTourStep & { requiresChangeAccess?: boolean })[] = [
|
||||
{
|
||||
id: "board-search",
|
||||
title: t("welcome.title"),
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("welcome.description")}
|
||||
documentationHref={createDocumentationLink(
|
||||
"/docs/getting-started/after-the-installation",
|
||||
"#creating-your-first-board",
|
||||
)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "board-edit-mode",
|
||||
title: t("editMode.title"),
|
||||
requiresChangeAccess: true,
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("editMode.description")}
|
||||
documentationHref={createDocumentationLink(
|
||||
"/docs/getting-started/after-the-installation",
|
||||
"#arrange-and-organize-your-board",
|
||||
)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "board-settings",
|
||||
title: t("settings.title"),
|
||||
requiresChangeAccess: true,
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("settings.description")}
|
||||
documentationHref={createDocumentationLink("/docs/management/boards")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "board-switcher",
|
||||
title: t("switcher.title"),
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("switcher.description")}
|
||||
documentationHref={createDocumentationLink("/docs/management/boards")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "board-user-menu",
|
||||
title: t("userMenu.title"),
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("userMenu.description")}
|
||||
documentationHref={createDocumentationLink("/docs/management/users")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return allSteps.filter((step) => !step.requiresChangeAccess || hasChangeAccess) as OnboardingTourStep[];
|
||||
}, [hasChangeAccess, t]);
|
||||
|
||||
return (
|
||||
<TourShell steps={steps} started={started} onEnd={handleEnd} position={{ base: "bottom", sm: "left" }}>
|
||||
{children}
|
||||
</TourShell>
|
||||
);
|
||||
};
|
||||
@@ -1,220 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import type { OnboardingTourFocusRevealProps, OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
|
||||
import { useMediaQuery } from "@mantine/hooks";
|
||||
import {
|
||||
IconAffiliateFilled,
|
||||
IconAppsFilled,
|
||||
IconLayoutDashboardFilled,
|
||||
IconPlus,
|
||||
IconUserFilled,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createDocumentationLink } from "@homarr/definitions";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { TourShell } from "./tour-shell";
|
||||
import { TourStepContent } from "./tour-step-content";
|
||||
|
||||
interface ManageTourProviderProps extends PropsWithChildren {
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
const stepRoutes: Record<string, string> = {
|
||||
"manage-welcome": "/manage",
|
||||
"manage-boards-list": "/manage/boards",
|
||||
"manage-boards-create": "/manage/boards",
|
||||
"manage-apps-list": "/manage/apps",
|
||||
"manage-apps-create": "/manage/apps",
|
||||
"manage-integrations-list": "/manage/integrations",
|
||||
"manage-integrations-create": "/manage/integrations",
|
||||
"manage-users-list": "/manage/users",
|
||||
"manage-users-create": "/manage/users",
|
||||
};
|
||||
|
||||
const usersStepFocusRevealProps: OnboardingTourFocusRevealProps = {
|
||||
withReveal: false,
|
||||
popoverProps: {
|
||||
position: "bottom",
|
||||
middlewares: { shift: { padding: 16 }, flip: true },
|
||||
},
|
||||
};
|
||||
|
||||
export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProps) => {
|
||||
const t = useScopedI18n("onboardingTour.manage");
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: tourStatus } = clientApi.user.getTourStatus.useQuery();
|
||||
const { mutate: completeTour } = clientApi.user.completeTour.useMutation({
|
||||
onSuccess() {
|
||||
void utils.user.getTourStatus.invalidate();
|
||||
},
|
||||
});
|
||||
const isMobile = useMediaQuery("(max-width: 48em)");
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
const isManageHome = /^(\/[^/]+)?\/manage\/?$/.test(pathname);
|
||||
const isManageSection = /^(\/[^/]+)?\/manage(\/.*)?$/.test(pathname);
|
||||
|
||||
const [tourActive, setTourActive] = useState(false);
|
||||
const [tourDismissed, setTourDismissed] = useState(false);
|
||||
const wasManageTourCompletedRef = useRef(tourStatus?.completedManageTour);
|
||||
|
||||
useEffect(() => {
|
||||
if (tourDismissed) return;
|
||||
if (isManageHome && tourStatus !== undefined && !tourStatus.completedManageTour && !isMobile) {
|
||||
setTourActive(true);
|
||||
}
|
||||
}, [isManageHome, tourStatus, isMobile, tourDismissed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isManageSection) {
|
||||
setTourActive(false);
|
||||
}
|
||||
}, [isManageSection]);
|
||||
|
||||
useEffect(() => {
|
||||
const wasCompleted = wasManageTourCompletedRef.current;
|
||||
const isCompleted = tourStatus?.completedManageTour;
|
||||
if (wasCompleted === true && isCompleted === false) {
|
||||
setTourDismissed(false);
|
||||
}
|
||||
wasManageTourCompletedRef.current = isCompleted;
|
||||
}, [tourStatus?.completedManageTour]);
|
||||
|
||||
const handleEnd = useCallback(() => {
|
||||
setTourDismissed(true);
|
||||
setTourActive(false);
|
||||
utils.user.getTourStatus.setData(undefined, {
|
||||
completedManageTour: true,
|
||||
completedBoardTour: tourStatus?.completedBoardTour ?? false,
|
||||
});
|
||||
completeTour({ tour: "manage" });
|
||||
router.push("/manage");
|
||||
}, [completeTour, router, tourStatus?.completedBoardTour, utils.user.getTourStatus]);
|
||||
|
||||
const steps = useMemo(() => {
|
||||
const allSteps: (OnboardingTourStep & { adminOnly?: boolean })[] = [
|
||||
{
|
||||
id: "manage-welcome",
|
||||
title: t("welcome.title"),
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("welcome.description")}
|
||||
documentationHref={createDocumentationLink("/docs/category/management")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "manage-boards-list",
|
||||
title: t("boardsList.title"),
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("boardsList.description")}
|
||||
documentationHref={createDocumentationLink("/docs/management/boards")}
|
||||
icon={<IconLayoutDashboardFilled size={18} />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "manage-boards-create",
|
||||
title: t("boardsCreate.title"),
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("boardsCreate.description")}
|
||||
documentationHref={createDocumentationLink("/docs/management/boards")}
|
||||
icon={<IconPlus size={18} />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "manage-apps-list",
|
||||
title: t("appsList.title"),
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("appsList.description")}
|
||||
documentationHref={createDocumentationLink("/docs/management/apps")}
|
||||
icon={<IconAppsFilled size={18} />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "manage-apps-create",
|
||||
title: t("appsCreate.title"),
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("appsCreate.description")}
|
||||
documentationHref={createDocumentationLink("/docs/management/apps")}
|
||||
icon={<IconPlus size={18} />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "manage-integrations-list",
|
||||
title: t("integrationsList.title"),
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("integrationsList.description")}
|
||||
documentationHref={createDocumentationLink("/docs/management/integrations")}
|
||||
icon={<IconAffiliateFilled size={18} />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "manage-integrations-create",
|
||||
title: t("integrationsCreate.title"),
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("integrationsCreate.description")}
|
||||
documentationHref={createDocumentationLink("/docs/management/integrations")}
|
||||
icon={<IconPlus size={18} />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "manage-users-list",
|
||||
title: t("usersList.title"),
|
||||
adminOnly: true,
|
||||
focusRevealProps: usersStepFocusRevealProps,
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("usersList.description")}
|
||||
documentationHref={createDocumentationLink("/docs/management/users")}
|
||||
icon={<IconUserFilled size={18} />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "manage-users-create",
|
||||
title: t("usersCreate.title"),
|
||||
adminOnly: true,
|
||||
focusRevealProps: usersStepFocusRevealProps,
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("usersCreate.description")}
|
||||
documentationHref={createDocumentationLink("/docs/management/users")}
|
||||
icon={<IconPlus size={18} />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return allSteps.filter((step) => !step.adminOnly || isAdmin) as OnboardingTourStep[];
|
||||
}, [isAdmin, t]);
|
||||
|
||||
return (
|
||||
<TourShell
|
||||
steps={steps}
|
||||
started={tourActive}
|
||||
onEnd={handleEnd}
|
||||
stepRoutes={stepRoutes}
|
||||
position={{ base: "bottom", sm: "right" }}
|
||||
>
|
||||
{children}
|
||||
</TourShell>
|
||||
);
|
||||
};
|
||||
@@ -1,197 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { OnboardingTourController, OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour";
|
||||
import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour";
|
||||
import { Button, Center, Group, Image, Kbd, Text } from "@mantine/core";
|
||||
import type { FloatingPosition } from "@mantine/core";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
|
||||
interface TourShellProps extends PropsWithChildren {
|
||||
steps: OnboardingTourStep[];
|
||||
started: boolean;
|
||||
onEnd: () => void;
|
||||
stepRoutes?: Record<string, string>;
|
||||
position?: FloatingPosition | Record<string, FloatingPosition>;
|
||||
}
|
||||
|
||||
type TourController = OnboardingTourController;
|
||||
|
||||
const stepIndexFromController = (controller: TourController) => controller.currentStepIndex ?? 0;
|
||||
|
||||
const stepAtOffset = (controller: TourController, offset: number) => {
|
||||
const index = stepIndexFromController(controller) + offset;
|
||||
return controller.tour[index];
|
||||
};
|
||||
|
||||
const pollForElement = (targetId: string, callback: () => void) => {
|
||||
const interval = setInterval(() => {
|
||||
const targetElement = document.querySelector(`[data-tour-target="${targetId}"]`);
|
||||
if (targetElement) {
|
||||
clearInterval(interval);
|
||||
callback();
|
||||
}
|
||||
}, 50);
|
||||
const timeout = setTimeout(() => clearInterval(interval), 5000);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
};
|
||||
|
||||
const isTypingTarget = (target: EventTarget | null) => {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
const tag = target.tagName;
|
||||
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target.isContentEditable;
|
||||
};
|
||||
|
||||
interface TourForwardButtonProps {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const TourForwardButton = ({ label, onClick }: TourForwardButtonProps) => {
|
||||
return (
|
||||
<Button size="sm" variant="light" onClick={onClick} rightSection={<Kbd size="xs">↵</Kbd>}>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
interface TourDoneButtonProps {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const TourDoneButton = ({ label, onClick }: TourDoneButtonProps) => {
|
||||
return (
|
||||
<Button size="sm" onClick={onClick} rightSection={<Kbd size="xs">↵</Kbd>}>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export const TourShell = ({ steps, started, onEnd, stepRoutes, position, children }: TourShellProps) => {
|
||||
const t = useI18n();
|
||||
const router = useRouter();
|
||||
const forwardActionRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const navigateAndAdvance = useCallback(
|
||||
(currentStepId: string | undefined, targetStepId: string | undefined, advance: () => void) => {
|
||||
if (!stepRoutes || !targetStepId) {
|
||||
advance();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentRoute = currentStepId ? stepRoutes[currentStepId] : undefined;
|
||||
const targetRoute = stepRoutes[targetStepId];
|
||||
|
||||
if (targetRoute && targetRoute !== currentRoute) {
|
||||
router.push(targetRoute);
|
||||
pollForElement(targetStepId, advance);
|
||||
} else {
|
||||
advance();
|
||||
}
|
||||
},
|
||||
[router, stepRoutes],
|
||||
);
|
||||
|
||||
const bindForwardAction = useCallback((controller: TourController, action: () => void) => {
|
||||
forwardActionRef.current = action;
|
||||
return action;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!started) {
|
||||
forwardActionRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return;
|
||||
if (isTypingTarget(event.target)) return;
|
||||
event.preventDefault();
|
||||
forwardActionRef.current?.();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [started]);
|
||||
|
||||
return (
|
||||
<OnboardingTour
|
||||
tour={steps}
|
||||
started={started}
|
||||
onOnboardingTourEnd={onEnd}
|
||||
withStepper={false}
|
||||
header={(controller) => (
|
||||
<>
|
||||
<Group justify="center" gap={4} py={4}>
|
||||
<Text size="sm" fw={600}>
|
||||
{stepIndexFromController(controller) + 1}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
/
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{controller.tour.length}
|
||||
</Text>
|
||||
</Group>
|
||||
{stepIndexFromController(controller) === 0 && (
|
||||
<Center py="xs">
|
||||
<Image src={homarrLogoPath} alt="Homarr" w={64} h={64} fit="contain" />
|
||||
</Center>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
nextStepNavigation={(controller) => {
|
||||
const nextStep = stepAtOffset(controller, 1);
|
||||
const action = bindForwardAction(controller, () => {
|
||||
navigateAndAdvance(controller.currentStep?.id, nextStep?.id, () => controller.nextStep());
|
||||
});
|
||||
return <TourForwardButton label={t("onboardingTour.next")} onClick={action} />;
|
||||
}}
|
||||
endStepNavigation={(controller) => {
|
||||
const action = bindForwardAction(controller, () => controller.endTour());
|
||||
return <TourDoneButton label={t("onboardingTour.done")} onClick={action} />;
|
||||
}}
|
||||
prevStepNavigation={(controller) => (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
const prevStep = stepAtOffset(controller, -1);
|
||||
navigateAndAdvance(controller.currentStep?.id, prevStep?.id, () => controller.prevStep());
|
||||
}}
|
||||
>
|
||||
{t("onboardingTour.prev")}
|
||||
</Button>
|
||||
)}
|
||||
skipNavigation={(controller) => (
|
||||
<Button size="sm" variant="subtle" color="gray" onClick={() => controller.skipTour()}>
|
||||
{t("onboardingTour.skip")}
|
||||
</Button>
|
||||
)}
|
||||
withPrevButton
|
||||
focusRevealProps={{
|
||||
disableTargetInteraction: true,
|
||||
popoverProps: {
|
||||
position: position ?? { base: "bottom", sm: "right" },
|
||||
width: 420,
|
||||
shadow: "xl",
|
||||
radius: "lg",
|
||||
middlewares: { shift: { padding: 16 }, flip: true },
|
||||
},
|
||||
}}
|
||||
cutoutPadding={12}
|
||||
cutoutRadius={12}
|
||||
>
|
||||
{children}
|
||||
</OnboardingTour>
|
||||
);
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Anchor, Divider, Group, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import { IconExternalLink } from "@tabler/icons-react";
|
||||
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
interface TourStepContentProps {
|
||||
description: string;
|
||||
documentationHref: string;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export const TourStepContent = ({ description, documentationHref, icon }: TourStepContentProps) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
{icon && (
|
||||
<ThemeIcon variant="light" size="lg" radius="md" mt={2}>
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
)}
|
||||
<Text size="sm" lh={1.7} style={{ flex: 1 }}>
|
||||
{description}
|
||||
</Text>
|
||||
</Group>
|
||||
<Divider />
|
||||
<Anchor href={documentationHref} target="_blank" size="xs" fw={500}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconExternalLink size={14} />
|
||||
{t("onboardingTour.learnMore")}
|
||||
</Group>
|
||||
</Anchor>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -45,7 +45,7 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
|
||||
window.location.assign(logoutUrl);
|
||||
return;
|
||||
}
|
||||
router.push("/auth/login");
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
}, [logoutUrl, openModal, router]);
|
||||
|
||||
@@ -3,7 +3,6 @@ import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env"
|
||||
export const env = createEnv({
|
||||
server: {
|
||||
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
|
||||
DEMO_MODE: createBooleanSchema(false),
|
||||
},
|
||||
experimental__runtimeEnv: process.env,
|
||||
});
|
||||
+10
-20
@@ -19,9 +19,6 @@
|
||||
|
||||
<!-- Links -->
|
||||
<p align="center">
|
||||
<a href="https://demo.homarr.dev">
|
||||
<strong>Live Demo 🎮</strong>
|
||||
</a> •
|
||||
<a href="https://homarr.dev/docs/category/installation-1/">
|
||||
<strong>Install 💻</strong>
|
||||
</a> •
|
||||
@@ -249,20 +246,13 @@
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="null" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/peanut.svg" alt="PeaNUT" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">PeaNUT</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/pi-hole" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/pi-hole.svg" alt="Pi-hole" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Pi-hole</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/plex" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/plex.svg" alt="Plex" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -303,15 +293,15 @@
|
||||
<br/>
|
||||
<p align="center">Radarr</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/readarr" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/readarr.svg" alt="Readarr" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Readarr</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/sabnzbd" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg" alt="SABnzbd" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -352,15 +342,15 @@
|
||||
<br/>
|
||||
<p align="center">Speedtest<br/>Tracker</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/tdarr" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/tdarr.png" alt="Tdarr" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Tdarr</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/tracearr" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/tracearr.svg" alt="Tracearr" width="90" height="90" />
|
||||
<br/>
|
||||
|
||||
@@ -31,7 +31,6 @@ describe("Onboarding", () => {
|
||||
confirmPassword: "Comp(exP4sswOrd",
|
||||
});
|
||||
await actions.processSettingsStepAsync();
|
||||
await actions.processIntegrationsStepAsync();
|
||||
|
||||
// Assert
|
||||
await assertions.assertFinishStepVisibleAsync();
|
||||
@@ -73,7 +72,6 @@ describe("Onboarding", () => {
|
||||
name: externalGroupName,
|
||||
});
|
||||
await actions.processSettingsStepAsync();
|
||||
await actions.processIntegrationsStepAsync();
|
||||
|
||||
// Assert
|
||||
await assertions.assertFinishStepVisibleAsync();
|
||||
|
||||
@@ -51,9 +51,4 @@ export class OnboardingActions {
|
||||
await this.page.waitForSelector("text=Analytics");
|
||||
await this.page.locator("css=button[type='submit']").click();
|
||||
}
|
||||
|
||||
public async processIntegrationsStepAsync() {
|
||||
await this.page.waitForSelector("text=integrations you'd like to connect");
|
||||
await this.page.locator("button", { hasText: "Skip for now" }).click();
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { eq } from "drizzle-orm";
|
||||
import { expect } from "vitest";
|
||||
|
||||
import * as sqliteSchema from "../../../packages/db/schema/sqlite";
|
||||
import type { OnboardingStep } from "../../../packages/definitions/src";
|
||||
import { OnboardingStep } from "../../../packages/definitions/src";
|
||||
import { credentialsAdminGroup } from "../../../packages/definitions/src/group";
|
||||
import type { SqliteDatabase } from "../e2e-db";
|
||||
|
||||
@@ -57,6 +57,6 @@ export class OnboardingAssertions {
|
||||
}
|
||||
|
||||
public async assertFinishStepVisibleAsync() {
|
||||
await this.page.waitForSelector("text=completed the setup", { timeout: 15000 });
|
||||
await this.page.waitForSelector("text=completed the setup", { timeout: 5000 });
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
# Homarr Docker label discovery example.
|
||||
# This compose file demonstrates all supported label combinations for testing.
|
||||
# Prerequisites: Homarr with Docker socket mounted.
|
||||
# In Manage → Tools → Import/Export, use the Docker Discovery panel to scan.
|
||||
|
||||
services:
|
||||
# ─── Full Homarr labels (all fields) ─────────────────────────────────────────
|
||||
sonarr:
|
||||
image: lscr.io/linuxserver/sonarr:latest
|
||||
container_name: homarr-sonarr
|
||||
ports:
|
||||
- "8989:8989"
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
homarr.name: "Sonarr"
|
||||
homarr.group: "Media"
|
||||
homarr.href: "http://sonarr:8989"
|
||||
homarr.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sonarr.svg"
|
||||
homarr.description: "TV series management"
|
||||
homarr.ping: "http://sonarr:8989/ping"
|
||||
homarr.id: "sonarr-main"
|
||||
homarr.board: "homelab"
|
||||
homarr.integration: "sonarr"
|
||||
homarr.widget: "downloads"
|
||||
|
||||
radarr:
|
||||
image: lscr.io/linuxserver/radarr:latest
|
||||
container_name: homarr-radarr
|
||||
ports:
|
||||
- "7878:7878"
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
homarr.name: "Radarr"
|
||||
homarr.group: "Media"
|
||||
homarr.href: "http://radarr:7878"
|
||||
homarr.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/radarr.svg"
|
||||
homarr.description: "Movie management"
|
||||
homarr.ping: "http://radarr:7878/ping"
|
||||
homarr.integration: "radarr"
|
||||
homarr.widget: "downloads"
|
||||
|
||||
# ─── Minimal Homarr labels (required fields only) ────────────────────────────
|
||||
portainer:
|
||||
image: portainer/portainer-ce:latest
|
||||
container_name: homarr-portainer
|
||||
ports:
|
||||
- "9000:9000"
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
homarr.name: "Portainer"
|
||||
homarr.group: "Infrastructure"
|
||||
homarr.href: "http://portainer:9000"
|
||||
|
||||
# ─── Homepage labels only (migration scenario) ───────────────────────────────
|
||||
grafana:
|
||||
image: grafana/grafana:latest
|
||||
container_name: homarr-grafana
|
||||
ports:
|
||||
- "3000:3000"
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
homepage.name: "Grafana"
|
||||
homepage.group: "Monitoring"
|
||||
homepage.href: "http://grafana:3000"
|
||||
homepage.icon: "grafana"
|
||||
homepage.description: "Metrics and dashboards"
|
||||
|
||||
prometheus:
|
||||
image: prom/prometheus:latest
|
||||
container_name: homarr-prometheus
|
||||
ports:
|
||||
- "9090:9090"
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
homepage.name: "Prometheus"
|
||||
homepage.group: "Monitoring"
|
||||
homepage.href: "http://prometheus:9090"
|
||||
homepage.icon: "prometheus"
|
||||
|
||||
# ─── Both label sets (homarr takes priority) ─────────────────────────────────
|
||||
qbittorrent:
|
||||
image: lscr.io/linuxserver/qbittorrent:latest
|
||||
container_name: homarr-qbittorrent
|
||||
ports:
|
||||
- "8080:8080"
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
homarr.name: "qBittorrent"
|
||||
homarr.group: "Downloads"
|
||||
homarr.href: "http://qbittorrent:8080"
|
||||
homarr.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/qbittorrent.svg"
|
||||
homarr.description: "Torrent client"
|
||||
homarr.ping: "http://qbittorrent:8080"
|
||||
homarr.integration: "qBittorrent"
|
||||
homarr.widget: "downloads"
|
||||
homepage.name: "qBittorrent (homepage)"
|
||||
homepage.group: "Downloads (homepage)"
|
||||
homepage.href: "http://qbittorrent:8080/homepage"
|
||||
homepage.icon: "qbittorrent"
|
||||
homepage.description: "Should be ignored"
|
||||
|
||||
# ─── Hidden container (excluded from discovery) ──────────────────────────────
|
||||
watchtower:
|
||||
image: containrrr/watchtower:latest
|
||||
container_name: homarr-watchtower
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
homarr.hide: "true"
|
||||
homarr.name: "Watchtower"
|
||||
homarr.group: "Infrastructure"
|
||||
homarr.href: "http://watchtower:8080"
|
||||
|
||||
# ─── Targeting a specific board ──────────────────────────────────────────────
|
||||
traefik:
|
||||
image: traefik:latest
|
||||
container_name: homarr-traefik
|
||||
ports:
|
||||
- "80:80"
|
||||
- "8081:8080"
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
homarr.name: "Traefik"
|
||||
homarr.group: "Networking"
|
||||
homarr.href: "http://traefik:8080"
|
||||
homarr.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/traefik.svg"
|
||||
homarr.board: "networking"
|
||||
homarr.ping: "http://traefik:8080/ping"
|
||||
|
||||
# ─── Custom external ID ──────────────────────────────────────────────────────
|
||||
jellyfin:
|
||||
image: jellyfin/jellyfin:latest
|
||||
container_name: homarr-jellyfin
|
||||
ports:
|
||||
- "8096:8096"
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
homarr.name: "Jellyfin"
|
||||
homarr.group: "Media"
|
||||
homarr.href: "http://jellyfin:8096"
|
||||
homarr.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyfin.svg"
|
||||
homarr.description: "Media streaming"
|
||||
homarr.ping: "http://jellyfin:8096/health"
|
||||
homarr.id: "jellyfin-production"
|
||||
homarr.integration: "jellyfin"
|
||||
homarr.widget: "mediaServer"
|
||||
|
||||
# ─── No discovery labels (should be completely ignored) ──────────────────────
|
||||
redis:
|
||||
image: redis:alpine
|
||||
container_name: homarr-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
restart: unless-stopped
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.64.0",
|
||||
"version": "1.61.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env CI=true turbo build",
|
||||
@@ -94,7 +94,7 @@
|
||||
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.2",
|
||||
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.2",
|
||||
"tmp@<=0.2.3": ">=0.2.7",
|
||||
"vite@>=5.0.0 <=5.4.18": ">=8.0.15"
|
||||
"vite@>=5.0.0 <=5.4.18": ">=8.0.14"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@types/node-unifi": "patches/@types__node-unifi.patch",
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@homarr/auth": "workspace:^0.1.0",
|
||||
"@homarr/board-portability": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/core": "workspace:^0.1.0",
|
||||
"@homarr/cron-job-status": "workspace:^0.1.0",
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env";
|
||||
import { createEnv } from "@homarr/core/infrastructure/env";
|
||||
|
||||
export const env = createEnv({
|
||||
server: {
|
||||
KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(),
|
||||
DEMO_MODE: createBooleanSchema(false),
|
||||
},
|
||||
runtimeEnv: {
|
||||
KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME,
|
||||
DEMO_MODE: process.env.DEMO_MODE,
|
||||
},
|
||||
});
|
||||
@@ -2,11 +2,11 @@ import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { env } from "@homarr/docker/env";
|
||||
|
||||
import { isDemoMode, publicProcedure } from "../trpc";
|
||||
import { publicProcedure } from "../trpc";
|
||||
|
||||
export const dockerMiddleware = () => {
|
||||
return publicProcedure.use(async ({ next }) => {
|
||||
if (env.ENABLE_DOCKER || isDemoMode) {
|
||||
if (env.ENABLE_DOCKER) {
|
||||
return await next();
|
||||
}
|
||||
throw new TRPCError({
|
||||
|
||||
@@ -28,7 +28,6 @@ export const appRouter = createTRPCRouter({
|
||||
certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)),
|
||||
bangs: lazy(() => import("./router/bangs/bangs-router").then((mod) => mod.bangsRouter)),
|
||||
info: lazy(() => import("./router/info").then((mod) => mod.infoRouter)),
|
||||
config: lazy(() => import("./router/config").then((mod) => mod.configRouter)),
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
@@ -35,12 +35,6 @@ import {
|
||||
getPermissionsWithParents,
|
||||
widgetKinds,
|
||||
} from "@homarr/definitions";
|
||||
import {
|
||||
exportBoardBundleAsync,
|
||||
importBoardBundleAsync,
|
||||
importHomepageServicesAsync,
|
||||
parseBundleJson,
|
||||
} from "@homarr/board-portability";
|
||||
import { importOldmarrAsync } from "@homarr/old-import";
|
||||
import { importJsonFileSchema } from "@homarr/old-import/shared";
|
||||
import { oldmarrConfigSchema } from "@homarr/old-schema";
|
||||
@@ -49,9 +43,6 @@ import {
|
||||
boardChangeVisibilitySchema,
|
||||
boardCreateSchema,
|
||||
boardDuplicateSchema,
|
||||
boardExportBundleSchema,
|
||||
boardImportBundleSchema,
|
||||
boardNameSchema,
|
||||
boardRenameSchema,
|
||||
boardSaveLayoutsSchema,
|
||||
boardSavePartialSettingsSchema,
|
||||
@@ -63,7 +54,6 @@ import { zodUnionFromArray } from "@homarr/validation/enums";
|
||||
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
|
||||
import { sectionSchema, sharedItemSchema } from "@homarr/validation/shared";
|
||||
|
||||
import packageJson from "../../../../package.json";
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "./board/board-access";
|
||||
import { generateResponsiveGridFor } from "./board/grid-algorithm";
|
||||
@@ -1337,113 +1327,6 @@ export const boardRouter = createTRPCRouter({
|
||||
const oldmarr = oldmarrConfigSchema.parse(JSON.parse(content));
|
||||
await importOldmarrAsync(ctx.db, oldmarr, input.configuration);
|
||||
}),
|
||||
exportBundle: protectedProcedure.input(boardExportBundleSchema).mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
|
||||
|
||||
const result = await exportBoardBundleAsync(ctx.db, input.id, packageJson.version);
|
||||
if (!result) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Board not found",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
filename: result.filename,
|
||||
content: result.content,
|
||||
};
|
||||
}),
|
||||
importBundle: permissionRequiredProcedure
|
||||
.requiresPermission("board-create")
|
||||
.input(boardImportBundleSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await noBoardWithSimilarNameAsync(ctx.db, input.name);
|
||||
|
||||
const bundle = parseBundleJson(input.content);
|
||||
|
||||
const existingApps = await ctx.db.query.apps.findMany();
|
||||
const existingIntegrations = await ctx.db.query.integrations.findMany();
|
||||
|
||||
const hasAccessForAll = ctx.session.user.permissions.includes("integration-use-all");
|
||||
const integrationIdsWithAccess = hasAccessForAll
|
||||
? []
|
||||
: await ctx.db
|
||||
.selectDistinct({
|
||||
id: integrationGroupPermissions.integrationId,
|
||||
})
|
||||
.from(integrationGroupPermissions)
|
||||
.leftJoin(groupMembers, eq(integrationGroupPermissions.groupId, groupMembers.groupId))
|
||||
.where(eq(groupMembers.userId, ctx.session.user.id))
|
||||
.union(
|
||||
ctx.db
|
||||
.selectDistinct({ id: integrationUserPermissions.integrationId })
|
||||
.from(integrationUserPermissions)
|
||||
.where(eq(integrationUserPermissions.userId, ctx.session.user.id)),
|
||||
)
|
||||
.then((result) => result.map((row) => row.id));
|
||||
|
||||
const report = await importBoardBundleAsync(
|
||||
ctx.db,
|
||||
bundle,
|
||||
input.name,
|
||||
ctx.session.user.id,
|
||||
existingApps,
|
||||
existingIntegrations,
|
||||
integrationIdsWithAccess,
|
||||
hasAccessForAll,
|
||||
);
|
||||
|
||||
return {
|
||||
boardId: report.boardId,
|
||||
created: report.created,
|
||||
warnings: report.warnings,
|
||||
};
|
||||
}),
|
||||
importHomepageServices: permissionRequiredProcedure
|
||||
.requiresPermission("board-create")
|
||||
.input(
|
||||
z.object({
|
||||
content: z.string().min(1),
|
||||
boardName: boardNameSchema,
|
||||
createIntegrations: z.boolean().default(true),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await noBoardWithSimilarNameAsync(ctx.db, input.boardName);
|
||||
|
||||
try {
|
||||
const report = await importHomepageServicesAsync(
|
||||
ctx.db,
|
||||
input.content,
|
||||
input.boardName,
|
||||
ctx.session.user.id,
|
||||
input.createIntegrations,
|
||||
);
|
||||
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, ctx.session.user.id),
|
||||
columns: {
|
||||
homeBoardId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user?.homeBoardId) {
|
||||
await ctx.db.update(users).set({ homeBoardId: report.boardId }).where(eq(users.id, ctx.session.user.id));
|
||||
}
|
||||
|
||||
return {
|
||||
boardId: report.boardId,
|
||||
created: report.created,
|
||||
warnings: report.warnings,
|
||||
unmappedWidgetTypes: report.unmappedWidgetTypes,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: error instanceof Error ? error.message : "Could not import Homepage services",
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
exportFullConfigAsync,
|
||||
importFullConfigAsync,
|
||||
parseAndValidateBundle,
|
||||
previewExportFullConfigAsync,
|
||||
previewImportFullConfigAsync,
|
||||
} from "@homarr/board-portability";
|
||||
|
||||
import packageJson from "../../../../package.json";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
|
||||
|
||||
export const configRouter = createTRPCRouter({
|
||||
previewExport: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
|
||||
return await previewExportFullConfigAsync(ctx.db);
|
||||
}),
|
||||
|
||||
previewImport: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(z.object({ content: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return await previewImportFullConfigAsync(ctx.db, input.content, packageJson.version);
|
||||
}),
|
||||
|
||||
exportFull: permissionRequiredProcedure.requiresPermission("admin").mutation(async ({ ctx }) => {
|
||||
return await exportFullConfigAsync(ctx.db, packageJson.version);
|
||||
}),
|
||||
|
||||
importFull: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(z.object({ content: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
const bundle = parseAndValidateBundle(input.content, packageJson.version);
|
||||
return await importFullConfigAsync(ctx.db, bundle, ctx.session.user.id);
|
||||
} catch (error) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: error instanceof Error ? error.message : "Invalid config" });
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -3,13 +3,8 @@ import { observable } from "@trpc/server/observable";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { Container, ContainerState, Docker, Port } from "@homarr/docker";
|
||||
import { DockerSingleton, listDiscoveredContainersAsync, syncDiscoveredServicesAsync } from "@homarr/docker";
|
||||
import { getServerSettingsAsync } from "@homarr/db/queries";
|
||||
import {
|
||||
dockerContainersRequestHandler,
|
||||
getContainerLogsAsync,
|
||||
streamContainerLogsAsync,
|
||||
} from "@homarr/request-handler/docker";
|
||||
import { DockerSingleton } from "@homarr/docker";
|
||||
import { dockerContainersRequestHandler } from "@homarr/request-handler/docker";
|
||||
|
||||
import { dockerMiddleware } from "../../middlewares/docker";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
|
||||
@@ -109,110 +104,6 @@ export const dockerRouter = createTRPCRouter({
|
||||
const innerHandler = dockerContainersRequestHandler.handler({});
|
||||
await innerHandler.invalidateAsync();
|
||||
}),
|
||||
getDiscoveredPreview: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(dockerMiddleware())
|
||||
.query(async ({ ctx }) => {
|
||||
const serverSettings = await getServerSettingsAsync(ctx.db);
|
||||
return await listDiscoveredContainersAsync({
|
||||
readHomepageLabels: serverSettings.docker.readHomepageLabels,
|
||||
});
|
||||
}),
|
||||
syncDiscovery: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(dockerMiddleware())
|
||||
.mutation(async ({ ctx }) => {
|
||||
const serverSettings = await getServerSettingsAsync(ctx.db);
|
||||
const discovered = await listDiscoveredContainersAsync({
|
||||
readHomepageLabels: serverSettings.docker.readHomepageLabels,
|
||||
});
|
||||
|
||||
return await syncDiscoveredServicesAsync(discovered, {
|
||||
targetBoardName: serverSettings.docker.targetBoardName,
|
||||
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
|
||||
forceDisableStatus: serverSettings.board.forceDisableStatus,
|
||||
defaultItemWidth: serverSettings.docker.defaultItemWidth,
|
||||
defaultItemHeight: serverSettings.docker.defaultItemHeight,
|
||||
});
|
||||
}),
|
||||
logs: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(dockerMiddleware())
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
tail: z.number().min(1).max(1000).optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const logs = await getContainerLogsAsync(input.id, input.tail ?? 200);
|
||||
|
||||
if (!logs) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Container not found",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
logs,
|
||||
};
|
||||
}),
|
||||
subscribeLogs: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(dockerMiddleware())
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
tail: z.number().min(1).max(1000).optional(),
|
||||
}),
|
||||
)
|
||||
.subscription(({ input }) => {
|
||||
return observable<string>((emit) => {
|
||||
let cleanupFn: (() => void) | undefined;
|
||||
let isSubscribed = true;
|
||||
|
||||
const initializeStreamAsync = async () => {
|
||||
try {
|
||||
cleanupFn = await streamContainerLogsAsync(
|
||||
input.id,
|
||||
input.tail ?? 200,
|
||||
(data) => {
|
||||
if (isSubscribed) {
|
||||
emit.next(data);
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
if (isSubscribed) {
|
||||
emit.error(
|
||||
new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: err.message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
if (isSubscribed) {
|
||||
emit.error(
|
||||
new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: err instanceof Error ? err.message : "Container not found",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void initializeStreamAsync();
|
||||
|
||||
return () => {
|
||||
isSubscribed = false;
|
||||
cleanupFn?.();
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
const getContainerOrDefaultAsync = async (instance: Docker, id: string) => {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { importFullConfigAsync, parseAndValidateBundle, previewImportFullConfigAsync } from "@homarr/board-portability";
|
||||
import { onboarding } from "@homarr/db/schema";
|
||||
import { analyseOldmarrImportForRouterAsync, analyseOldmarrImportInputSchema } from "@homarr/old-import/analyse";
|
||||
import {
|
||||
ensureValidTokenOrThrow,
|
||||
@@ -10,7 +7,6 @@ import {
|
||||
importInitialOldmarrInputSchema,
|
||||
} from "@homarr/old-import/import";
|
||||
|
||||
import packageJson from "../../../../../package.json";
|
||||
import { createTRPCRouter, onboardingProcedure } from "../../trpc";
|
||||
import { nextOnboardingStepAsync } from "../onboard/onboard-queries";
|
||||
|
||||
@@ -23,7 +19,12 @@ export const importRouter = createTRPCRouter({
|
||||
}),
|
||||
validateToken: onboardingProcedure
|
||||
.requiresStep("import")
|
||||
.input(z.object({ checksum: z.string(), token: z.string() }))
|
||||
.input(
|
||||
z.object({
|
||||
checksum: z.string(),
|
||||
token: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(({ input }) => {
|
||||
try {
|
||||
ensureValidTokenOrThrow(input.checksum, input.token);
|
||||
@@ -39,28 +40,4 @@ export const importRouter = createTRPCRouter({
|
||||
await importInitialOldmarrAsync(ctx.db, input);
|
||||
await nextOnboardingStepAsync(ctx.db, undefined);
|
||||
}),
|
||||
previewInitialConfigImport: onboardingProcedure
|
||||
.requiresStep("import")
|
||||
.input(z.object({ content: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return await previewImportFullConfigAsync(ctx.db, input.content, packageJson.version);
|
||||
}),
|
||||
importInitialConfigImport: onboardingProcedure
|
||||
.requiresStep("import")
|
||||
.input(z.object({ content: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
const bundle = parseAndValidateBundle(input.content, packageJson.version);
|
||||
await importFullConfigAsync(ctx.db, bundle, null);
|
||||
|
||||
const hasUsers = (bundle.users ?? []).length > 0;
|
||||
if (hasUsers) {
|
||||
await ctx.db.update(onboarding).set({ previousStep: "import", step: "finish" });
|
||||
} else {
|
||||
await nextOnboardingStepAsync(ctx.db, undefined);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: error instanceof Error ? error.message : "Invalid config" });
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import z from "zod/v4";
|
||||
|
||||
import packageJson from "../../../../package.json";
|
||||
import { createTRPCRouter, isDemoMode, publicProcedure, protectedProcedure } from "../trpc";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
|
||||
export const infoRouter = createTRPCRouter({
|
||||
getInfo: protectedProcedure
|
||||
@@ -13,5 +13,4 @@ export const infoRouter = createTRPCRouter({
|
||||
version: packageJson.version,
|
||||
};
|
||||
}),
|
||||
isDemoMode: publicProcedure.query(() => isDemoMode),
|
||||
});
|
||||
Loaded 100 of 326 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user