Compare commits

..
Author SHA1 Message Date
ajnart 68d4956077 style: auto-fix lint and format 2026-06-04 21:26:19 +00:00
Thomas Camlong 84588c956d feat: consolidate feature 2026-06-04 23:25:31 +02:00
Thomas Camlong 73550ce998 Merge remote-tracking branch 'origin/dev' into feat/static-board-ssr 2026-06-04 23:09:38 +02:00
Thomas Camlong 71d7332505 fix: add async keyword to getInitialBoardAsync function 2026-05-25 09:36:15 +02:00
Thomas Camlong eae51a8447 perf(board): deduplicate board fetch with React cache()
Wrap getInitialBoardAsync in React cache() so the same board data is
fetched once per request instead of 3 times (layout, page, metadata).
This is the same pattern used for auth() in the codebase.
2026-05-22 12:52:15 +02:00
Thomas Camlong e9308db5dc fix(board): align edit mode with dev's removal of loading overlay
Remove useIsBoardReady and LoadingOverlay from edit mode board,
aligning with the dev branch's perf improvement that shows the board
immediately without waiting for ready state.
2026-05-22 11:59:47 +02:00
Thomas Camlong fb06b55845 Merge remote-tracking branch 'origin/dev' into feat/static-board-ssr
# Conflicts:
#	apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx
2026-05-22 11:58:58 +02:00
Thomas Camlong 108b488250 feat(widgets): add server-side prefetch for weather and RSS feed
Prefetch weather and RSS feed data during SSR so widgets render with
content on first paint instead of showing loading states.
2026-05-22 11:38:02 +02:00
Thomas Camlong 10cedbd906 fix(widgets): eliminate layout shift with fallback dimensions
Provide estimated width/height to widgets before useElementSize
measures the DOM, preventing responsive breakpoint shifts during
hydration. Replace the Loader spinner with a Skeleton for widget
loading states.
2026-05-22 11:37:51 +02:00
Thomas Camlong f53527e2e7 fix(board): make SectionContext optional for view mode
Add useOptionalSectionContext to gracefully handle rendering outside
GridStack (view mode). The item menu now uses optional chaining for
gridstack access instead of throwing when context is absent.
2026-05-22 11:37:39 +02:00
Thomas Camlong 121cff20d7 feat(board): add SSR-compatible static grid for view mode
Split board rendering into two modes: a static CSS Grid for view mode
(SSR-friendly, no JS required for layout) and a lazy-loaded GridStack
for edit mode. This eliminates widget "pop-in" on page load by rendering
the full grid layout server-side.
2026-05-22 11:37:25 +02:00
Thomas Camlong e14da6f747 feat: auto-open widget settings on add with all integrations pre-selected
When adding a widget from the "Choose item to add" modal, the widget
edit modal now opens immediately after placement, allowing the user to
configure options and integrations before the widget renders.

All available integrations for the widget are selected by default.
2026-05-22 09:58:29 +02:00
275 changed files with 3644 additions and 18874 deletions

No files matched your search

-1
View File
@@ -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
-64
View File
@@ -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
+1 -16
View File
@@ -5,21 +5,9 @@ import "@homarr/common/env";
import "@homarr/core/infrastructure/logs/env";
import "@homarr/docker/env";
import { readFileSync } from "fs";
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
const homarrVersion = (() => {
for (const candidate of ["../../package.json", "./package.json"]) {
try {
const pkg = JSON.parse(readFileSync(candidate, "utf-8"));
if (pkg.name === "homarr") return pkg.version as string;
} catch {}
}
return "unknown";
})();
// Package path does not work... so we need to use relative path
const withNextIntl = createNextIntlPlugin({
experimental: {
@@ -29,9 +17,6 @@ const withNextIntl = createNextIntlPlugin({
});
const nextConfig: NextConfig = {
env: {
HOMARR_VERSION: homarrVersion,
},
output: "standalone",
reactStrictMode: true,
// react compiler breaks mantine-react-table, so disabled for now
@@ -42,7 +27,7 @@ const nextConfig: NextConfig = {
* dockerode is required in the external server packages because of https://github.com/homarr-labs/homarr/issues/612
* isomorphic-dompurify and jsdom are required, see https://github.com/kkomelin/isomorphic-dompurify/issues/356
*/
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom"],
experimental: {
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
turbopackFileSystemCacheForDev: true,
-8
View File
@@ -18,7 +18,6 @@
"@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",
@@ -72,13 +71,9 @@
"clsx": "catalog:",
"dayjs": "catalog:",
"dotenv": "catalog:",
"drizzle-orm": "catalog:",
"flag-icons": "catalog:",
"glob": "catalog:",
"adm-zip": "catalog:",
"better-sqlite3": "catalog:",
"isomorphic-dompurify": "catalog:",
"jszip": "catalog:",
"jotai": "catalog:",
"mantine-react-table": "catalog:",
"next": "catalog:",
@@ -89,7 +84,6 @@
"react-error-boundary": "catalog:",
"react-simple-code-editor": "catalog:",
"sass": "catalog:",
"sql.js": "catalog:",
"superjson": "catalog:",
"swagger-ui-react": "catalog:",
"use-deep-compare-effect": "catalog:",
@@ -98,8 +92,6 @@
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/adm-zip": "catalog:",
"@types/better-sqlite3": "catalog:",
"@types/chroma-js": "catalog:",
"@types/node": "catalog:",
"@types/prismjs": "catalog:",
Binary file not shown.
@@ -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}
@@ -1,9 +1,11 @@
import { cache } from "react";
import { api } from "@homarr/api/server";
import { createBoardContentPage } from "../_creator";
const getHomeBoardAsync = cache(() => api.board.getHomeBoard());
export default createBoardContentPage<{ locale: string }>({
async getInitialBoardAsync() {
return await api.board.getHomeBoard();
},
getInitialBoardAsync: getHomeBoardAsync,
});
@@ -1,9 +1,11 @@
import { cache } from "react";
import { api } from "@homarr/api/server";
import { createBoardContentPage } from "../../_creator";
const getBoardByNameAsync = cache((name: string) => api.board.getBoardByName({ name }));
export default createBoardContentPage<{ locale: string; name: string }>({
async getInitialBoardAsync({ name }) {
return await api.board.getBoardByName({ name });
},
getInitialBoardAsync: async ({ name }) => getBoardByNameAsync(name),
});
@@ -1,33 +1,24 @@
"use client";
import { Box, Stack } from "@mantine/core";
import dynamic from "next/dynamic";
import { Box } from "@mantine/core";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { BoardCategorySection } from "~/components/board/sections/category-section";
import { BoardEmptySection } from "~/components/board/sections/empty-section";
import { StaticBoardGrid } from "~/components/board/sections/static-grid";
import { BoardBackgroundVideo } from "~/components/layout/background";
const EditModeBoard = dynamic(() => import("./_edit-board"), { ssr: false });
export const ClientBoard = () => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const fullWidthSortedSections = board.sections
.filter((section) => section.kind === "empty" || section.kind === "category")
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
const [isEditMode] = useEditMode();
return (
<Box h="100%" pos="relative">
<BoardBackgroundVideo />
<Stack h="100%">
{fullWidthSortedSections.map((section) =>
section.kind === "empty" ? (
<BoardEmptySection key={`${currentLayoutId}-${section.id}`} section={section} />
) : (
<BoardCategorySection key={`${currentLayoutId}-${section.id}`} section={section} />
),
)}
</Stack>
{isEditMode ? <EditModeBoard /> : <StaticBoardGrid board={board} />}
</Box>
);
};
@@ -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> }) => {
@@ -1,7 +1,3 @@
"use client";
import dynamic from "next/dynamic";
export const DynamicClientBoard = dynamic(() => import("./_client").then((mod) => mod.ClientBoard), {
ssr: false,
});
export { ClientBoard as DynamicClientBoard } from "./_client";
@@ -0,0 +1,31 @@
"use client";
import { Stack } from "@mantine/core";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { BoardCategorySection } from "~/components/board/sections/category-section";
import { BoardEmptySection } from "~/components/board/sections/empty-section";
const EditModeBoard = () => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const fullWidthSortedSections = board.sections
.filter((section) => section.kind === "empty" || section.kind === "category")
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
return (
<Stack h="100%">
{fullWidthSortedSections.map((section) =>
section.kind === "empty" ? (
<BoardEmptySection key={`${currentLayoutId}-${section.id}`} section={section} />
) : (
<BoardCategorySection key={`${currentLayoutId}-${section.id}`} section={section} />
),
)}
</Stack>
);
};
export default EditModeBoard;
@@ -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;
@@ -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";
@@ -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,14 +1,13 @@
"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";
@@ -21,7 +20,7 @@ export const InitImport = () => {
if (!file) {
return (
<InitStepCard>
<Card w={64 * 12 + 8} maw="90vw">
<ImportDropZone
loading={isPending}
updateFile={(file) => {
@@ -41,12 +40,12 @@ export const InitImport = () => {
});
}}
/>
</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,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,20 +1,16 @@
import { Stack, Text } from "@mantine/core";
import { Card, Stack, Text } from "@mantine/core";
import { IconFileImport, IconPlayerPlay } from "@tabler/icons-react";
import { getMantineColor } from "@homarr/common";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { getScopedI18n } from "@homarr/translation/server";
import { InitStepCard } from "../../_components/init-step-card";
import { InitSqliteRestore } from "./sqlite-restore";
import { InitStartButton } from "./next-button";
export const InitStart = async () => {
const tStart = await getScopedI18n("init.step.start");
const isSqlite = dbEnv.DRIVER === "better-sqlite3";
return (
<InitStepCard>
<Card w={64 * 6} maw="90vw">
<Stack>
<Text>{tStart("description")}</Text>
@@ -30,8 +26,7 @@ export const InitStart = async () => {
>
{tStart("action.importOldmarr")}
</InitStartButton>
{isSqlite && <InitSqliteRestore />}
</Stack>
</InitStepCard>
</Card>
);
};
@@ -1,43 +0,0 @@
"use client";
import { useState } from "react";
import { Button, Collapse, Stack, Text } from "@mantine/core";
import { IconDatabaseImport } from "@tabler/icons-react";
import { getMantineColor } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { DatabaseRestoreFlow } from "~/components/backup";
export const InitSqliteRestore = () => {
const t = useScopedI18n("init.step.start.action");
const [expanded, setExpanded] = useState(false);
if (expanded) {
return (
<Stack gap="sm">
<Button
variant="subtle"
size="xs"
onClick={() => setExpanded(false)}
>
{t("backToOptions")}
</Button>
<Text size="sm" c="dimmed">
{t("restoreSqliteDescription")}
</Text>
<DatabaseRestoreFlow variant="standalone" />
</Stack>
);
}
return (
<Button
onClick={() => setExpanded(true)}
variant="default"
leftSection={<IconDatabaseImport color={getMantineColor("orange", 6)} size={16} stroke={1.5} />}
>
{t("restoreSqlite")}
</Button>
);
};
@@ -1,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>
);
};
+1 -4
View File
@@ -9,12 +9,10 @@ import { getScopedI18n } from "@homarr/translation/server";
import { CurrentColorSchemeCombobox } from "~/components/color-scheme/current-color-scheme-combobox";
import { 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">
-2
View File
@@ -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>
);
@@ -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>
);
};
@@ -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>
+1 -24
View File
@@ -10,7 +10,6 @@ import {
IconBrandTablerFilled,
IconCertificate,
IconClipboardListFilled,
IconDatabaseExport,
IconDirectionsFilled,
IconGitFork,
IconHelpSquareRoundedFilled,
@@ -29,7 +28,6 @@ import {
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { createDocumentationLink } from "@homarr/definitions";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { env } from "@homarr/docker/env";
import { getScopedI18n } from "@homarr/translation/server";
@@ -38,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");
@@ -48,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,
@@ -64,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,
@@ -81,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"),
@@ -156,12 +146,6 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
href: "/manage/tools/tasks",
hidden: !session?.user.permissions.includes("admin"),
},
{
label: t("items.tools.items.backup"),
icon: IconDatabaseExport,
href: "/manage/tools/backup",
hidden: !session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3",
},
],
},
{
@@ -169,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"),
@@ -208,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,87 +0,0 @@
"use client";
import { useState } from "react";
import { Alert, Button, Card, Group, Stack, Text, Title } from "@mantine/core";
import { IconAlertTriangle, IconDatabaseExport, IconDownload } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { DangerousActionConfirmation } from "~/components/backup/dangerous-action-confirmation";
export const BackupExportCard = () => {
const t = useScopedI18n("management.page.tool.backup.export");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showConfirm, setShowConfirm] = useState(false);
const handleExport = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch("/api/backup/export");
if (!response.ok) {
const errorMessage = await response.json().then(
(data) => data.error,
() => `Server returned ${response.status}`,
);
throw new Error(errorMessage ?? t("error"));
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const filename = response.headers.get("Content-Disposition")?.match(/filename="(.+)"/)?.[1] ?? "homarr-backup.zip";
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
setShowConfirm(false);
} catch (err) {
setError(err instanceof Error ? err.message : t("error"));
} finally {
setLoading(false);
}
};
return (
<Card withBorder>
<Stack gap="sm">
<Group gap="sm">
<IconDatabaseExport size={24} />
<Title order={4}>{t("title")}</Title>
</Group>
<Text size="sm" c="dimmed">
{t("description")}
</Text>
{error && (
<Alert color="red" icon={<IconAlertTriangle size={16} />}>
{error}
</Alert>
)}
{showConfirm ? (
<DangerousActionConfirmation
title={t("confirm.title")}
warningTitle={t("confirm.warningTitle")}
warningBody={t("confirm.warningBody")}
typePrompt={t("confirm.typePrompt", { phrase: "I understand" })}
submitLabel={t("confirm.submit")}
submitIcon={<IconDownload size={16} />}
cancelLabel={t("confirm.cancel")}
onConfirm={handleExport}
onCancel={() => setShowConfirm(false)}
disabled={loading}
color="orange"
/>
) : (
<Group>
<Button leftSection={<IconDownload size={16} />} loading={loading} onClick={() => setShowConfirm(true)}>
{t("button")}
</Button>
</Group>
)}
</Stack>
</Card>
);
};
@@ -1,27 +0,0 @@
"use client";
import { Card, Group, Stack, Text, Title } from "@mantine/core";
import { IconDatabaseImport } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { DatabaseRestoreFlow } from "~/components/backup";
export const BackupImportCard = () => {
const t = useScopedI18n("management.page.tool.backup.import");
return (
<Card withBorder>
<Stack gap="md">
<Group gap="sm">
<IconDatabaseImport size={24} />
<Title order={4}>{t("title")}</Title>
</Group>
<Text size="sm" c="dimmed">
{t("description")}
</Text>
<DatabaseRestoreFlow />
</Stack>
</Card>
);
};
@@ -1,40 +0,0 @@
import { notFound } from "next/navigation";
import { Stack, Title } from "@mantine/core";
import { auth } from "@homarr/auth/next";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { getScopedI18n } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata";
import { BackupExportCard } from "./_components/backup-export-card";
import { BackupImportCard } from "./_components/backup-import-card";
export async function generateMetadata() {
const session = await auth();
if (!session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3") {
return {};
}
const t = await getScopedI18n("management.page.tool.backup");
return {
title: createMetaTitle(t("metaTitle")),
};
}
export default async function BackupPage() {
const session = await auth();
if (!session?.user.permissions.includes("admin") || dbEnv.DRIVER !== "better-sqlite3") {
notFound();
}
const t = await getScopedI18n("management.page.tool.backup");
return (
<Stack>
<Title>{t("title")}</Title>
<BackupExportCard />
<BackupImportCard />
</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>
</>
);
}
@@ -1,3 +0,0 @@
.outerTerminal > div {
height: 100%;
}
@@ -1,81 +0,0 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { Box } from "@mantine/core";
import { CanvasAddon } from "@xterm/addon-canvas";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import { clientApi } from "@homarr/api/client";
import classes from "./terminal.module.css";
interface DockerLogsTerminalProps {
containerId: string;
}
export const DockerLogsTerminal = ({ containerId }: DockerLogsTerminalProps) => {
const ref = useRef<HTMLDivElement>(null);
const terminalRef = useRef<Terminal | null>(null);
const pendingLogsRef = useRef<string[]>([]);
const isTerminalReadyRef = useRef(false);
const writeToTerminal = useCallback((data: string) => {
if (!isTerminalReadyRef.current || !terminalRef.current) {
pendingLogsRef.current.push(data);
return;
}
terminalRef.current.write(data);
terminalRef.current.refresh(0, terminalRef.current.rows - 1);
}, []);
clientApi.docker.subscribeLogs.useSubscription(
{ id: containerId, tail: 200 },
{
onData: writeToTerminal,
onError(err) {
writeToTerminal(`\r\n\x1b[31m${err.message}\x1b[0m`);
},
},
);
useEffect(() => {
if (!ref.current) {
return () => undefined;
}
const canvasAddon = new CanvasAddon();
terminalRef.current = new Terminal({
cursorBlink: false,
disableStdin: true,
convertEol: true,
fontSize: 14,
});
terminalRef.current.open(ref.current);
terminalRef.current.loadAddon(canvasAddon);
isTerminalReadyRef.current = true;
const terminal = terminalRef.current;
pendingLogsRef.current.forEach((data) => terminal.write(data));
pendingLogsRef.current = [];
terminal.refresh(0, terminal.rows - 1);
setTimeout(() => {
const fitAddon = new FitAddon();
terminalRef.current?.loadAddon(fitAddon);
fitAddon.fit();
});
return () => {
isTerminalReadyRef.current = false;
pendingLogsRef.current = [];
canvasAddon.dispose();
terminalRef.current?.dispose();
terminalRef.current = null;
};
}, [writeToTerminal]);
return <Box ref={ref} className={classes.outerTerminal} h="100%" />;
};
@@ -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 />
@@ -13,11 +13,9 @@ import classes from "./terminal.module.css";
export const TerminalComponent = () => {
const ref = useRef<HTMLDivElement>(null);
const { activeLevels, fontSize } = useLogContext();
const { activeLevels } = useLogContext();
const terminalRef = useRef<Terminal>(null);
const fitAddonRef = useRef<FitAddon>(null);
clientApi.log.subscribe.useSubscription(
{
levels: activeLevels,
@@ -28,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);
},
},
@@ -44,30 +43,22 @@ export const TerminalComponent = () => {
cursorBlink: false,
disableStdin: true,
convertEol: true,
fontSize,
});
terminalRef.current.open(ref.current);
terminalRef.current.loadAddon(canvasAddon);
// This is a hack to make sure the terminal is rendered before we try to fit it
// You can blame @Meierschlumpf for this
setTimeout(() => {
const fitAddon = new FitAddon();
fitAddonRef.current = fitAddon;
terminalRef.current?.loadAddon(fitAddon);
fitAddon.fit();
});
return () => {
canvasAddon.dispose();
terminalRef.current?.dispose();
terminalRef.current = null;
canvasAddon.dispose();
};
}, []);
useEffect(() => {
if (!terminalRef.current) return;
terminalRef.current.options.fontSize = fontSize;
fitAddonRef.current?.fit();
}, [fontSize]);
return <Box ref={ref} id="terminal" className={classes.outerTerminal} h="100%"></Box>;
};
@@ -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();
@@ -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>
);
}
@@ -1,75 +0,0 @@
import fs from "fs";
import path from "path";
import { NextResponse } from "next/server";
import AdmZip from "adm-zip";
import Database from "better-sqlite3";
import { auth } from "@homarr/auth/next";
import { env } from "@homarr/common/env";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
export async function GET() {
const session = await auth();
if (!session?.user.permissions.includes("admin")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
if (dbEnv.DRIVER !== "better-sqlite3") {
return NextResponse.json({ error: "SQLite backup is only available for SQLite databases" }, { status: 400 });
}
const dbPath = dbEnv.URL as string | undefined;
if (!dbPath || !fs.existsSync(dbPath)) {
return NextResponse.json({ error: "Database file not found" }, { status: 500 });
}
const tempPath = path.join(path.dirname(dbPath), "db.backup.sqlite");
let sourceDb: InstanceType<typeof Database> | null = null;
try {
sourceDb = new Database(dbPath, { readonly: true });
sourceDb.pragma("wal_checkpoint(TRUNCATE)");
sourceDb.exec(`VACUUM INTO '${tempPath}'`);
sourceDb.close();
sourceDb = null;
const dbBuffer = fs.readFileSync(tempPath);
const metadata = {
homarrVersion: process.env.HOMARR_VERSION ?? "unknown",
exportedAt: new Date().toISOString(),
dbDialect: "sqlite",
encryptionKey: env.SECRET_ENCRYPTION_KEY,
};
const zip = new AdmZip();
zip.addFile("db.sqlite", dbBuffer);
zip.addFile("metadata.json", Buffer.from(JSON.stringify(metadata, null, 2)));
const zipBuffer = zip.toBuffer();
const date = new Date().toISOString().split("T")[0];
const filename = `homarr-backup-${date}.zip`;
return new NextResponse(zipBuffer, {
status: 200,
headers: {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="${filename}"`,
"Content-Length": zipBuffer.length.toString(),
},
});
} catch (error) {
console.error("[backup/export] Export failed:", error);
const message = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json({ error: `Export failed: ${message}` }, { status: 500 });
} finally {
sourceDb?.close();
try {
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
} catch (cleanupErr) {
console.error("[backup/export] Failed to clean up temp file:", cleanupErr);
}
}
}
@@ -1,194 +0,0 @@
import crypto from "crypto";
import fs from "fs";
import path from "path";
import { NextResponse } from "next/server";
import AdmZip from "adm-zip";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { auth } from "@homarr/auth/next";
import { env } from "@homarr/common/env";
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { db } from "@homarr/db";
import { findMigrationsFolder } from "../shared";
const REQUIRED_ZIP_ENTRIES = ["db.sqlite", "metadata.json"] as const;
const ALGORITHM = "aes-256-cbc";
const HEX_KEY_REGEX = /^[0-9a-fA-F]{64}$/;
const reEncryptSecrets = (tempDb: InstanceType<typeof Database>, importedKeyHex: string) => {
const currentKeyHex = env.SECRET_ENCRYPTION_KEY;
if (importedKeyHex === currentKeyHex) return;
if (!HEX_KEY_REGEX.test(importedKeyHex)) {
throw new Error("Imported encryption key is not a valid 64-character hex string");
}
const oldKey = Buffer.from(importedKeyHex, "hex");
const newKey = Buffer.from(currentKeyHex, "hex");
const rows = tempDb.prepare('SELECT "integration_id", "kind", "value" FROM "integrationSecret"').all() as {
integration_id: string;
kind: string;
value: string;
}[];
if (rows.length === 0) return;
const updateStmt = tempDb.prepare('UPDATE "integrationSecret" SET "value" = ? WHERE "integration_id" = ? AND "kind" = ?');
const transaction = tempDb.transaction(() => {
for (const row of rows) {
const parts = row.value.split(".");
if (parts.length !== 2) {
throw new Error(
`Malformed secret value for integration ${row.integration_id} (${row.kind}): expected "data.iv" format`,
);
}
const [data, dataIv] = parts as [string, string];
try {
const iv = Buffer.from(dataIv, "hex");
const encrypted = Buffer.from(data, "hex");
const decipher = crypto.createDecipheriv(ALGORITHM, oldKey, iv);
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString();
const newIv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(ALGORITHM, newKey, newIv);
const newEncrypted = Buffer.concat([cipher.update(decrypted), cipher.final()]);
const newValue = `${newEncrypted.toString("hex")}.${newIv.toString("hex")}`;
updateStmt.run(newValue, row.integration_id, row.kind);
} catch {
throw new Error(`Failed to re-encrypt secret for integration ${row.integration_id} (${row.kind})`);
}
}
});
transaction();
};
const isOnboardingActiveAsync = async (): Promise<boolean> => {
const onboardingRow = await db.query.onboarding.findFirst();
if (!onboardingRow) return false;
return onboardingRow.step === "start";
};
export async function POST(req: Request) {
const session = await auth();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const isOnboarding = !isAdmin && (await isOnboardingActiveAsync());
if (!isAdmin && !isOnboarding) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
if (dbEnv.DRIVER !== "better-sqlite3") {
return NextResponse.json({ error: "SQLite restore is only available for SQLite databases" }, { status: 400 });
}
const dbPath = dbEnv.URL as string | undefined;
if (!dbPath) {
return NextResponse.json({ error: "Database path not configured" }, { status: 500 });
}
let formData: FormData;
try {
formData = await req.formData();
} catch {
return NextResponse.json({ error: "Invalid form data" }, { status: 400 });
}
const file = formData.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
const arrayBuffer = await file.arrayBuffer();
let zip: AdmZip;
try {
zip = new AdmZip(Buffer.from(arrayBuffer));
} catch {
return NextResponse.json({ error: "Invalid ZIP file" }, { status: 400 });
}
const entries = zip.getEntries().map((e) => e.entryName);
const missingEntries = REQUIRED_ZIP_ENTRIES.filter((name) => !entries.includes(name));
if (missingEntries.length > 0) {
return NextResponse.json({ error: `Invalid backup: missing ${missingEntries.join(", ")}` }, { status: 400 });
}
const migrationsFolder = findMigrationsFolder();
if (!migrationsFolder) {
return NextResponse.json({ error: "Migration files not found" }, { status: 500 });
}
const tempPath = path.join(path.dirname(dbPath), "db.import.sqlite");
let tempDb: InstanceType<typeof Database> | null = null;
try {
const dbEntry = zip.getEntry("db.sqlite")!;
fs.writeFileSync(tempPath, dbEntry.getData());
tempDb = new Database(tempPath);
const drizzleDb = drizzle(tempDb, { casing: DB_CASING });
migrate(drizzleDb, { migrationsFolder });
const metadataEntry = zip.getEntry("metadata.json")!;
const metadata = JSON.parse(metadataEntry.getData().toString());
const rawKey = metadata.encryptionKey ?? zip.getEntry("encryption-key.txt")?.getData().toString().trim();
const importedKey = typeof rawKey === "string" && rawKey.length > 0 ? rawKey : undefined;
const secretCount = (
tempDb.prepare('SELECT COUNT(*) as count FROM "integrationSecret"').get() as { count: number }
).count;
if (secretCount > 0 && !importedKey) {
throw new Error(
"Backup contains integration secrets but no encryption key. " +
"Cannot restore without the original SECRET_ENCRYPTION_KEY.",
);
}
if (importedKey) {
reEncryptSecrets(tempDb, importedKey);
}
tempDb.close();
tempDb = null;
fs.renameSync(tempPath, dbPath);
for (const suffix of ["-wal", "-shm"] as const) {
try {
const sidecar = `${dbPath}${suffix}`;
if (fs.existsSync(sidecar)) fs.unlinkSync(sidecar);
} catch (cleanupErr) {
console.error(`Failed to remove ${suffix} file:`, cleanupErr);
}
}
setTimeout(() => {
console.log("Database restored, restarting server...");
process.exit(0);
}, 500);
return NextResponse.json({ success: true, message: "Database restored. Server is restarting..." });
} catch (error) {
console.error("[backup/import] Restore failed:", error);
const message = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json({ error: `Restore failed: ${message}` }, { status: 500 });
} finally {
tempDb?.close();
if (fs.existsSync(tempPath)) {
try {
fs.unlinkSync(tempPath);
} catch {
// temp file may have been renamed already
}
}
}
}
@@ -1,63 +0,0 @@
import fs from "fs";
import path from "path";
import { NextResponse } from "next/server";
import { findMigrationsFolder } from "../shared";
interface JournalEntry {
idx: number;
version: string;
when: number;
tag: string;
breakpoints: boolean;
}
interface MigrationFile {
idx: number;
tag: string;
sql: string;
when: number;
}
export async function GET() {
try {
const migrationsFolder = findMigrationsFolder();
if (!migrationsFolder) {
return NextResponse.json({ error: "Migration files not found" }, { status: 500 });
}
const journalPath = path.join(migrationsFolder, "meta", "_journal.json");
if (!fs.existsSync(journalPath)) {
return NextResponse.json({ error: "Migration journal not found" }, { status: 500 });
}
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8")) as { entries: JournalEntry[] };
const migrations: MigrationFile[] = [];
for (const entry of journal.entries) {
const sqlPath = path.join(migrationsFolder, `${entry.tag}.sql`);
if (!fs.existsSync(sqlPath)) continue;
migrations.push({
idx: entry.idx,
tag: entry.tag,
sql: fs.readFileSync(sqlPath, "utf-8"),
when: entry.when,
});
}
return NextResponse.json(
{ total: migrations.length, migrations },
{
headers: {
"Cache-Control": "public, max-age=3600",
},
},
);
} catch (error) {
console.error("[backup/migrations] Failed to read migrations:", error);
const message = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json({ error: `Failed to read migrations: ${message}` }, { status: 500 });
}
}
-12
View File
@@ -1,12 +0,0 @@
import fs from "fs";
import path from "path";
const migrationsFolderCandidates = [
path.join(process.cwd(), "db/migrations/sqlite"),
path.join(process.cwd(), "packages/db/migrations/sqlite"),
path.resolve(process.cwd(), "../../db/migrations/sqlite"),
"/app/db/migrations/sqlite",
];
export const findMigrationsFolder = () =>
migrationsFolderCandidates.find((candidate) => fs.existsSync(candidate));
@@ -1,30 +0,0 @@
import fs from "fs";
import path from "path";
import { NextResponse } from "next/server";
const wasmCandidates = [
path.join(process.cwd(), "public/sql-wasm.wasm"),
path.join(process.cwd(), "apps/nextjs/public/sql-wasm.wasm"),
path.resolve(process.cwd(), "../../node_modules/sql.js/dist/sql-wasm.wasm"),
path.join(process.cwd(), "node_modules/sql.js/dist/sql-wasm.wasm"),
];
let cachedWasm: Buffer | null = null;
export async function GET() {
if (!cachedWasm) {
const found = wasmCandidates.find((p) => fs.existsSync(p));
if (!found) {
return NextResponse.json({ error: "WASM binary not found" }, { status: 500 });
}
cachedWasm = fs.readFileSync(found);
}
return new NextResponse(cachedWasm, {
headers: {
"Content-Type": "application/wasm",
"Cache-Control": "public, max-age=31536000, immutable",
},
});
}
@@ -1,308 +0,0 @@
import crypto from "crypto";
import fs from "fs";
import os from "os";
import path from "path";
import AdmZip from "adm-zip";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
const ALGORITHM = "aes-256-cbc";
const MIGRATIONS_FOLDER = "./packages/db/migrations/sqlite";
const generateHexKey = () => crypto.randomBytes(32).toString("hex");
const encryptWithKey = (text: string, keyHex: string): `${string}.${string}` => {
const key = Buffer.from(keyHex, "hex");
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
let encrypted = cipher.update(text);
encrypted = Buffer.concat([encrypted, cipher.final()]);
return `${encrypted.toString("hex")}.${iv.toString("hex")}`;
};
const decryptWithKey = (value: `${string}.${string}`, keyHex: string): string => {
const key = Buffer.from(keyHex, "hex");
const [data, dataIv] = value.split(".") as [string, string];
const iv = Buffer.from(dataIv, "hex");
const encrypted = Buffer.from(data, "hex");
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
let decrypted = decipher.update(encrypted);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString();
};
const createTestDb = (dbPath: string) => {
const sqlite = new Database(dbPath);
const db = drizzle(sqlite, { casing: DB_CASING });
migrate(db, { migrationsFolder: MIGRATIONS_FOLDER });
return { db, sqlite };
};
describe("SQLite backup", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "homarr-backup-test-"));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe("export ZIP structure", () => {
it("should create a ZIP containing all required entries", () => {
const dbPath = path.join(tmpDir, "db.sqlite");
const { sqlite } = createTestDb(dbPath);
const backupPath = path.join(tmpDir, "db.backup.sqlite");
sqlite.pragma("wal_checkpoint(TRUNCATE)");
sqlite.exec(`VACUUM INTO '${backupPath}'`);
sqlite.close();
const dbBuffer = fs.readFileSync(backupPath);
const metadata = {
homarrVersion: "test",
exportedAt: new Date().toISOString(),
dbDialect: "sqlite",
encryptionKey: generateHexKey(),
};
const zip = new AdmZip();
zip.addFile("db.sqlite", dbBuffer);
zip.addFile("metadata.json", Buffer.from(JSON.stringify(metadata)));
const zipPath = path.join(tmpDir, "backup.zip");
zip.writeZip(zipPath);
const readBack = new AdmZip(zipPath);
const entryNames = readBack.getEntries().map((e) => e.entryName).sort();
expect(entryNames).toEqual(["db.sqlite", "metadata.json"]);
const dbEntry = readBack.getEntry("db.sqlite");
expect(dbEntry).toBeDefined();
expect(dbEntry!.header.size).toBeGreaterThan(0);
});
it("should produce a valid SQLite database in the ZIP", () => {
const dbPath = path.join(tmpDir, "db.sqlite");
const { sqlite } = createTestDb(dbPath);
const backupPath = path.join(tmpDir, "db.backup.sqlite");
sqlite.exec(`VACUUM INTO '${backupPath}'`);
sqlite.close();
const backupDb = new Database(backupPath, { readonly: true });
const tables = backupDb
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
.all() as { name: string }[];
expect(tables.map((t) => t.name)).toContain("board");
expect(tables.map((t) => t.name)).toContain("user");
expect(tables.map((t) => t.name)).toContain("integrationSecret");
backupDb.close();
});
});
describe("import ZIP validation", () => {
it("should detect missing required entries", () => {
const zip = new AdmZip();
zip.addFile("db.sqlite", Buffer.from("dummy"));
const entries = zip.getEntries().map((e) => e.entryName);
const required = ["db.sqlite", "metadata.json"];
const missing = required.filter((name) => !entries.includes(name));
expect(missing).toEqual(["metadata.json"]);
});
it("should accept valid backup ZIP with all entries", () => {
const zip = new AdmZip();
zip.addFile("db.sqlite", Buffer.from("dummy"));
zip.addFile("metadata.json", Buffer.from(JSON.stringify({ dbDialect: "sqlite", encryptionKey: generateHexKey() })));
const entries = zip.getEntries().map((e) => e.entryName);
const required = ["db.sqlite", "metadata.json"];
const missing = required.filter((name) => !entries.includes(name));
expect(missing).toHaveLength(0);
});
it("should accept legacy backup ZIP with encryption-key.txt", () => {
const zip = new AdmZip();
zip.addFile("db.sqlite", Buffer.from("dummy"));
zip.addFile("encryption-key.txt", Buffer.from(generateHexKey()));
zip.addFile("metadata.json", Buffer.from(JSON.stringify({ dbDialect: "sqlite" })));
const entries = zip.getEntries().map((e) => e.entryName);
const required = ["db.sqlite", "metadata.json"];
const missing = required.filter((name) => !entries.includes(name));
expect(missing).toHaveLength(0);
});
});
describe("secret re-encryption", () => {
it("should re-encrypt secrets from old key to new key", () => {
const dbPath = path.join(tmpDir, "db.sqlite");
const { sqlite } = createTestDb(dbPath);
const oldKey = generateHexKey();
const newKey = generateHexKey();
const plaintext = "my-secret-password";
const encryptedValue = encryptWithKey(plaintext, oldKey);
sqlite.exec(`
INSERT INTO "integration" ("id", "name", "url", "kind")
VALUES ('int-1', 'Test', 'http://test', 'adGuardHome')
`);
sqlite.exec(`
INSERT INTO "integrationSecret" ("integration_id", "kind", "value", "updated_at")
VALUES ('int-1', 'apiKey', '${encryptedValue}', ${Math.floor(Date.now() / 1000)})
`);
const decryptedBefore = decryptWithKey(encryptedValue, oldKey);
expect(decryptedBefore).toBe(plaintext);
const oldKeyBuf = Buffer.from(oldKey, "hex");
const newKeyBuf = Buffer.from(newKey, "hex");
const rows = sqlite
.prepare('SELECT "integration_id", "kind", "value" FROM "integrationSecret"')
.all() as { integration_id: string; kind: string; value: string }[];
const updateStmt = sqlite.prepare(
'UPDATE "integrationSecret" SET "value" = ? WHERE "integration_id" = ? AND "kind" = ?',
);
for (const row of rows) {
const [data, dataIv] = row.value.split(".") as [string, string];
const iv = Buffer.from(dataIv, "hex");
const encrypted = Buffer.from(data, "hex");
const decipher = crypto.createDecipheriv(ALGORITHM, oldKeyBuf, iv);
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString();
const newIv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(ALGORITHM, newKeyBuf, newIv);
const newEncrypted = Buffer.concat([cipher.update(decrypted), cipher.final()]);
const newValue = `${newEncrypted.toString("hex")}.${newIv.toString("hex")}`;
updateStmt.run(newValue, row.integration_id, row.kind);
}
const updatedRow = sqlite
.prepare('SELECT "value" FROM "integrationSecret" WHERE "integration_id" = ?')
.get("int-1") as { value: string };
const decryptedAfter = decryptWithKey(updatedRow.value as `${string}.${string}`, newKey);
expect(decryptedAfter).toBe(plaintext);
expect(() => decryptWithKey(updatedRow.value as `${string}.${string}`, oldKey)).toThrow();
sqlite.close();
});
it("should skip re-encryption when keys match", () => {
const dbPath = path.join(tmpDir, "db.sqlite");
const { sqlite } = createTestDb(dbPath);
const sameKey = generateHexKey();
const plaintext = "my-secret";
const encryptedValue = encryptWithKey(plaintext, sameKey);
sqlite.exec(`
INSERT INTO "integration" ("id", "name", "url", "kind")
VALUES ('int-1', 'Test', 'http://test', 'adGuardHome')
`);
sqlite.exec(`
INSERT INTO "integrationSecret" ("integration_id", "kind", "value", "updated_at")
VALUES ('int-1', 'apiKey', '${encryptedValue}', ${Math.floor(Date.now() / 1000)})
`);
const rowBefore = sqlite
.prepare('SELECT "value" FROM "integrationSecret" WHERE "integration_id" = ?')
.get("int-1") as { value: string };
expect(rowBefore.value).toBe(encryptedValue);
const decrypted = decryptWithKey(rowBefore.value as `${string}.${string}`, sameKey);
expect(decrypted).toBe(plaintext);
sqlite.close();
});
});
describe("VACUUM INTO consistency", () => {
it("should produce a consistent copy via VACUUM INTO", () => {
const dbPath = path.join(tmpDir, "db.sqlite");
const { sqlite } = createTestDb(dbPath);
sqlite.exec(`
INSERT INTO "app" ("id", "name", "icon_url")
VALUES ('app-1', 'TestApp', 'https://example.com/icon.png')
`);
const backupPath = path.join(tmpDir, "db.vacuum.sqlite");
sqlite.pragma("wal_checkpoint(TRUNCATE)");
sqlite.exec(`VACUUM INTO '${backupPath}'`);
const backupDb = new Database(backupPath, { readonly: true });
const app = backupDb.prepare('SELECT "name" FROM "app" WHERE "id" = ?').get("app-1") as { name: string } | undefined;
expect(app).toBeDefined();
expect(app!.name).toBe("TestApp");
backupDb.close();
sqlite.close();
});
});
describe("migration on imported DB", () => {
it("should successfully migrate an imported database", () => {
const sourcePath = path.join(tmpDir, "source.sqlite");
const { sqlite: sourceDb } = createTestDb(sourcePath);
sourceDb.exec(`
INSERT INTO "board" ("id", "name", "is_public", "primary_color", "secondary_color", "opacity", "item_radius", "background_image_attachment", "background_image_repeat", "background_image_size", "disable_status")
VALUES ('board-1', 'TestBoard', 0, '#fa5252', '#fd7e14', 100, 'lg', 'fixed', 'no-repeat', 'cover', 0)
`);
sourceDb.close();
const importPath = path.join(tmpDir, "import.sqlite");
fs.copyFileSync(sourcePath, importPath);
const importDb = new Database(importPath);
const drizzleDb = drizzle(importDb, { casing: DB_CASING });
migrate(drizzleDb, { migrationsFolder: MIGRATIONS_FOLDER });
const board = importDb.prepare('SELECT "name" FROM "board" WHERE "id" = ?').get("board-1") as { name: string } | undefined;
expect(board).toBeDefined();
expect(board!.name).toBe("TestBoard");
importDb.close();
});
});
describe("encryption key validation", () => {
it("should reject invalid hex keys", () => {
const invalidKeys = ["short", "not-hex-at-all-this-is-invalid-key-that-is-sixty-four-char!", ""];
const hexKeyRegex = /^[0-9a-fA-F]{64}$/;
for (const key of invalidKeys) {
expect(hexKeyRegex.test(key)).toBe(false);
}
});
it("should accept valid 64-char hex keys", () => {
const validKey = generateHexKey();
const hexKeyRegex = /^[0-9a-fA-F]{64}$/;
expect(hexKeyRegex.test(validKey)).toBe(true);
});
});
});
@@ -1,114 +0,0 @@
"use client";
import { Badge, Group, Paper, Stack, Table, Text, ThemeIcon, Title } from "@mantine/core";
import { IconCheck, IconDatabase, IconGitBranch } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import type { BackupAnalysis } from "./types";
import { PREVIEW_TABLE_KEYS } from "./types";
interface BackupPreviewPanelProps {
analysis: BackupAnalysis;
}
export const BackupPreviewPanel = ({ analysis }: BackupPreviewPanelProps) => {
const t = useScopedI18n("management.page.tool.backup.restore.preview");
const exportDate = new Date(analysis.metadata.exportedAt).toLocaleString();
const pendingCount = analysis.migrations.pending.length;
const hasPendingMigrations = pendingCount > 0;
return (
<Stack gap="md">
<Paper p="md" radius="md" withBorder>
<Stack gap="sm">
<Group gap="sm">
<ThemeIcon variant="light" color="blue" size="lg" radius="xl">
<IconDatabase size={18} />
</ThemeIcon>
<div>
<Title order={5}>{t("title")}</Title>
<Text size="xs" c="dimmed">
{t("exported", { date: exportDate })}
</Text>
</div>
<Badge variant="light" color="gray" ml="auto">
Homarr {analysis.metadata.homarrVersion}
</Badge>
</Group>
<Table striped withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("entity")}</Table.Th>
<Table.Th ta="right">{t("count")}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{PREVIEW_TABLE_KEYS.map((key) => (
<Table.Tr key={key}>
<Table.Td>{t(`entities.${key}` as never)}</Table.Td>
<Table.Td ta="right">
<Group gap={4} justify="flex-end">
{(analysis.counts[key] ?? 0) > 0 && (
<IconCheck size={14} color="var(--mantine-color-green-6)" />
)}
<Text size="sm" ff="monospace">{analysis.counts[key] ?? 0}</Text>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
{analysis.boardNames.length > 0 && (
<Group gap="xs" wrap="wrap">
<Text size="sm" fw={500}>{t("boards")}:</Text>
{analysis.boardNames.map((name) => (
<Badge key={name} variant="light" size="sm">{name}</Badge>
))}
</Group>
)}
</Stack>
</Paper>
{hasPendingMigrations && (
<Paper p="md" radius="md" withBorder bd="1px solid var(--mantine-color-orange-8)">
<Group gap="sm">
<ThemeIcon variant="light" color="orange" size="lg" radius="xl">
<IconGitBranch size={18} />
</ThemeIcon>
<div>
<Text size="sm" fw={600}>
{pendingCount === 1
? t("migrationsApplied", { count: String(pendingCount) })
: t("migrationsAppliedPlural", { count: String(pendingCount) })}
</Text>
<Text size="xs" c="dimmed">
{pendingCount === 1
? t("migrationsOlderVersion", { count: String(pendingCount) })
: t("migrationsOlderVersionPlural", { count: String(pendingCount) })}
</Text>
</div>
<Badge variant="light" color="orange" ml="auto">
{t("migrationsPending", { count: String(pendingCount) })}
</Badge>
</Group>
</Paper>
)}
{!hasPendingMigrations && (
<Paper p="md" radius="md" withBorder>
<Group gap="sm">
<ThemeIcon variant="light" color="green" size="lg" radius="xl">
<IconGitBranch size={18} />
</ThemeIcon>
<Text size="sm" fw={500} c="dimmed">
{t("migrationsUpToDate", { count: String(analysis.migrations.total) })}
</Text>
</Group>
</Paper>
)}
</Stack>
);
};
@@ -1,91 +0,0 @@
"use client";
import type { ReactNode } from "react";
import { useState } from "react";
import { Alert, Box, Button, Group, Paper, Stack, Text, TextInput } from "@mantine/core";
import { IconAlertTriangle, IconShieldExclamation } from "@tabler/icons-react";
const CONFIRMATION_PHRASE = "I understand";
interface DangerousActionConfirmationProps {
title: string;
warningTitle: string;
warningBody: string;
typePrompt: string;
submitLabel: string;
submitIcon: ReactNode;
cancelLabel: string;
onConfirm: () => void;
onCancel: () => void;
disabled?: boolean;
color?: string;
}
export const DangerousActionConfirmation = ({
title,
warningTitle,
warningBody,
typePrompt,
submitLabel,
submitIcon,
cancelLabel,
onConfirm,
onCancel,
disabled,
color = "red",
}: DangerousActionConfirmationProps) => {
const [value, setValue] = useState("");
const isMatch = value.trim().toLowerCase() === CONFIRMATION_PHRASE.toLowerCase();
return (
<Paper p="lg" radius="md" withBorder bd={`1px solid var(--mantine-color-${color}-9)`}>
<Stack gap="md">
<Group gap="sm">
<IconShieldExclamation size={24} color={`var(--mantine-color-${color}-6)`} />
<Text size="lg" fw={700} c={color}>
{title}
</Text>
</Group>
<Alert color={color} variant="light" icon={<IconAlertTriangle size={20} />}>
<Stack gap={4}>
<Text size="sm" fw={600}>{warningTitle}</Text>
<Text size="sm">{warningBody}</Text>
</Stack>
</Alert>
<Box>
<Text size="sm" fw={500} mb={4}>
{typePrompt}
</Text>
<TextInput
value={value}
onChange={(e) => setValue(e.currentTarget.value)}
placeholder={CONFIRMATION_PHRASE}
disabled={disabled}
styles={{
input: {
fontFamily: "var(--mantine-font-family-monospace)",
borderColor: isMatch ? "var(--mantine-color-green-6)" : undefined,
},
}}
/>
</Box>
<Group justify="flex-end">
<Button variant="subtle" color="gray" onClick={onCancel} disabled={disabled}>
{cancelLabel}
</Button>
<Button
color={color}
leftSection={submitIcon}
onClick={onConfirm}
disabled={!isMatch || disabled}
>
{submitLabel}
</Button>
</Group>
</Stack>
</Paper>
);
};
@@ -1,227 +0,0 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { Alert, Button, Group, Loader, rem, Stack, Text } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import { Dropzone, MIME_TYPES } from "@mantine/dropzone";
import { IconAlertTriangle, IconArrowRight, IconFileZip, IconUpload, IconX } from "@tabler/icons-react";
import "@mantine/dropzone/styles.css";
import { useScopedI18n } from "@homarr/translation/client";
import type { RestoreStep } from "./types";
import { BackupPreviewPanel } from "./backup-preview-panel";
import { MigrationProgressPanel } from "./migration-progress-panel";
import { RestoreConfirmation } from "./restore-confirmation";
import { RestoreProgressPanel } from "./restore-progress-panel";
import { useBackupAnalysis } from "./use-backup-analysis";
interface DatabaseRestoreFlowProps {
variant?: "card" | "standalone";
onRestoreComplete?: () => void;
}
export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: DatabaseRestoreFlowProps) => {
const t = useScopedI18n("management.page.tool.backup.restore");
const [file, setFile] = useState<FileWithPath | null>(null);
const [step, setStep] = useState<RestoreStep>("upload");
const [importError, setImportError] = useState<string | null>(null);
const apiDoneRef = useRef(false);
const animDoneRef = useRef(false);
const apiErrorRef = useRef<string | null>(null);
const { analysis, loading, error: analysisError, migrationProgress, analyzeFile, reset } = useBackupAnalysis();
const handleFileDrop = useCallback(
(files: FileWithPath[]) => {
const droppedFile = files[0];
if (!droppedFile) return;
setFile(droppedFile);
setStep("preview");
setImportError(null);
void analyzeFile(droppedFile);
},
[analyzeFile],
);
const handleClear = useCallback(() => {
setFile(null);
setStep("upload");
setImportError(null);
apiDoneRef.current = false;
animDoneRef.current = false;
apiErrorRef.current = null;
reset();
}, [reset]);
const tryFinalize = useCallback(() => {
if (!apiDoneRef.current) return;
if (apiErrorRef.current) {
setImportError(apiErrorRef.current);
setStep("error");
return;
}
if (!animDoneRef.current) return;
onRestoreComplete?.();
window.location.reload();
}, [onRestoreComplete]);
const handleConfirm = useCallback(async () => {
if (!file) return;
setStep("restoring");
setImportError(null);
apiDoneRef.current = false;
animDoneRef.current = false;
apiErrorRef.current = null;
try {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/backup/import", { method: "POST", body: formData });
if (!response.ok) {
try {
const data = await response.json();
apiErrorRef.current = data.error ?? t("failed.title");
} catch {
apiErrorRef.current = `Server returned ${response.status}`;
}
}
} catch {
apiErrorRef.current = t("failed.title");
} finally {
apiDoneRef.current = true;
tryFinalize();
}
}, [file, t, tryFinalize]);
const handleAnimationComplete = useCallback(() => {
animDoneRef.current = true;
tryFinalize();
}, [tryFinalize]);
if (step === "upload") {
return (
<Dropzone
onDrop={handleFileDrop}
acceptColor="blue.6"
rejectColor="red.6"
accept={[MIME_TYPES.zip, "application/x-zip-compressed"]}
multiple={false}
maxSize={1024 * 1024 * 256}
radius="md"
>
<Group justify="center" gap="xl" mih={variant === "standalone" ? 200 : 160} style={{ pointerEvents: "none" }}>
<Dropzone.Accept>
<IconUpload style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-blue-6)" }} stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-red-6)" }} stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconFileZip style={{ width: rem(52), height: rem(52), color: "var(--mantine-color-dimmed)" }} stroke={1.5} />
</Dropzone.Idle>
<div>
<Text size="xl" inline>
{t("dropzone.title")}
</Text>
<Text size="sm" c="dimmed" inline mt={7}>
{t("dropzone.description")}
</Text>
</div>
</Group>
</Dropzone>
);
}
if (step === "preview") {
return (
<Stack gap="md">
<Group justify="space-between">
<Group gap="sm">
<IconFileZip size={20} />
<div>
<Text size="sm" fw={500}>{file?.name}</Text>
<Text size="xs" c="dimmed">{file ? `${(file.size / 1024 / 1024).toFixed(2)} MB` : ""}</Text>
</div>
</Group>
<Button variant="subtle" size="xs" onClick={handleClear}>
{t("changeFile")}
</Button>
</Group>
{loading && migrationProgress && (
<MigrationProgressPanel progress={migrationProgress} />
)}
{loading && !migrationProgress && (
<Group justify="center" py="xl">
<Loader size="sm" />
<Text size="sm" c="dimmed">{t("analyzing")}</Text>
</Group>
)}
{analysisError && (
<Alert color="red" icon={<IconAlertTriangle size={16} />} title={t("analysisError")}>
{analysisError}
</Alert>
)}
{analysis && (
<>
<BackupPreviewPanel analysis={analysis} />
<Group justify="flex-end">
<Button
rightSection={<IconArrowRight size={16} />}
onClick={() => setStep("confirm")}
>
{t("continueToRestore")}
</Button>
</Group>
</>
)}
</Stack>
);
}
if (step === "confirm") {
return (
<Stack gap="md">
{analysis && <BackupPreviewPanel analysis={analysis} />}
<RestoreConfirmation
onConfirm={handleConfirm}
onCancel={() => setStep("preview")}
/>
</Stack>
);
}
if (step === "restoring") {
return (
<RestoreProgressPanel
active
migrations={analysis?.migrations.pending ?? []}
onComplete={handleAnimationComplete}
/>
);
}
if (step === "error") {
return (
<Stack gap="md">
<Alert color="red" icon={<IconAlertTriangle size={16} />} title={t("failed.title")} radius="md">
<Text size="sm">{importError}</Text>
</Alert>
<Group>
<Button variant="subtle" onClick={handleClear}>
{t("tryAgain")}
</Button>
</Group>
</Stack>
);
}
return null;
};
@@ -1,3 +0,0 @@
export { DatabaseRestoreFlow } from "./database-restore-flow";
export { DangerousActionConfirmation } from "./dangerous-action-confirmation";
export type { BackupAnalysis, RestoreStep } from "./types";
@@ -1,60 +0,0 @@
"use client";
import { Box, Group, Loader, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core";
import { IconCheck, IconDatabase, IconLoader } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import type { MigrationProgress } from "./use-backup-analysis";
interface MigrationProgressPanelProps {
progress: MigrationProgress;
}
export const MigrationProgressPanel = ({ progress }: MigrationProgressPanelProps) => {
const t = useScopedI18n("management.page.tool.backup.restore.migration");
return (
<Paper p="md" radius="md" bg="var(--mantine-color-dark-7)">
<Stack gap="sm">
<Group gap="xs">
<ThemeIcon size="sm" variant="light" color="blue" radius="xl">
<IconDatabase size={12} />
</ThemeIcon>
<Text size="sm" fw={600}>
{t("title", { current: String(progress.current), total: String(progress.total) })}
</Text>
</Group>
<Box pl={4}>
<Timeline active={progress.current - 1} bulletSize={20} lineWidth={2} color="blue">
{Array.from({ length: progress.total }, (_, i) => {
const isDone = i < progress.current - 1 || (i === progress.current - 1 && progress.phase === "done");
const isActive = i === progress.current - 1 && progress.phase === "applying";
return (
<Timeline.Item
key={i}
bullet={
isDone ? (
<IconCheck size={12} />
) : isActive ? (
<Loader size={10} color="white" />
) : (
<IconLoader size={12} />
)
}
title={
<Text size="xs" ff="monospace" c={isDone ? "dimmed" : isActive ? "white" : "dimmed"}>
{i === progress.current - 1 ? progress.tag : t("migrationLabel", { index: String(i + 1) })}
</Text>
}
/>
);
})}
</Timeline>
</Box>
</Stack>
</Paper>
);
};
@@ -1,32 +0,0 @@
"use client";
import { IconDatabaseImport } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { DangerousActionConfirmation } from "./dangerous-action-confirmation";
interface RestoreConfirmationProps {
onConfirm: () => void;
onCancel: () => void;
disabled?: boolean;
}
export const RestoreConfirmation = ({ onConfirm, onCancel, disabled }: RestoreConfirmationProps) => {
const t = useScopedI18n("management.page.tool.backup.restore.confirm");
return (
<DangerousActionConfirmation
title={t("title")}
warningTitle={t("warningTitle")}
warningBody={t("warningBody")}
typePrompt={t("typePrompt", { phrase: "I understand" })}
submitLabel={t("submit")}
submitIcon={<IconDatabaseImport size={16} />}
cancelLabel={t("cancel")}
onConfirm={onConfirm}
onCancel={onCancel}
disabled={disabled}
/>
);
};
@@ -1,159 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Box, Group, Loader, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core";
import {
IconCheck,
IconDatabaseImport,
IconFileZip,
IconKey,
IconLoader,
IconRefresh,
IconTransform,
} from "@tabler/icons-react";
import type { Icon } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import type { MigrationFile } from "./types";
const BASE_PHASE_DURATIONS: Record<string, number> = {
extracting: 1200,
encrypting: 1500,
swapping: 800,
restarting: 10000,
};
const MIGRATION_STEP_DURATION = 100;
interface TimelineStep {
key: string;
label: string;
icon: Icon;
duration: number;
}
interface RestoreProgressPanelProps {
active: boolean;
migrations: MigrationFile[];
onComplete?: () => void;
}
export const RestoreProgressPanel = ({ active, migrations, onComplete }: RestoreProgressPanelProps) => {
const t = useScopedI18n("management.page.tool.backup.restore.progress");
const [currentStepIdx, setCurrentStepIdx] = useState(-1);
const steps = useMemo((): TimelineStep[] => {
const result: TimelineStep[] = [
{ key: "extracting", label: t("extracting"), icon: IconFileZip, duration: BASE_PHASE_DURATIONS.extracting! },
];
if (migrations.length > 0) {
for (const migration of migrations) {
result.push({
key: `migration-${migration.idx}`,
label: migration.tag,
icon: IconDatabaseImport,
duration: MIGRATION_STEP_DURATION,
});
}
} else {
result.push({
key: "migrating",
label: t("migrating"),
icon: IconDatabaseImport,
duration: 300,
});
}
result.push(
{ key: "encrypting", label: t("encrypting"), icon: IconKey, duration: BASE_PHASE_DURATIONS.encrypting! },
{ key: "swapping", label: t("swapping"), icon: IconTransform, duration: BASE_PHASE_DURATIONS.swapping! },
{ key: "restarting", label: t("restarting"), icon: IconRefresh, duration: BASE_PHASE_DURATIONS.restarting! },
);
return result;
}, [migrations, t]);
useEffect(() => {
if (!active) return;
let cancelled = false;
const run = async () => {
for (let i = 0; i < steps.length; i++) {
if (cancelled) return;
setCurrentStepIdx(i);
await new Promise((r) => setTimeout(r, steps[i]!.duration));
}
if (!cancelled) {
onComplete?.();
}
};
void run();
return () => { cancelled = true; };
}, [active, onComplete, steps]);
if (!active && currentStepIdx === -1) return null;
return (
<Paper p="lg" radius="md" bg="var(--mantine-color-dark-7)">
<Stack gap="md">
<Group gap="xs">
<Loader size="sm" color="blue" />
<Text size="sm" fw={600}>
{t("title")}
</Text>
</Group>
<Box pl={4}>
<Timeline
active={currentStepIdx}
bulletSize={24}
lineWidth={2}
color="blue"
>
{steps.map((step, i) => {
const isDone = i < currentStepIdx;
const isActive = i === currentStepIdx;
const isMigration = step.key.startsWith("migration-");
return (
<Timeline.Item
key={step.key}
bullet={
isDone ? (
<ThemeIcon size={24} radius="xl" color="green" variant="filled">
<IconCheck size={14} />
</ThemeIcon>
) : isActive ? (
<ThemeIcon size={24} radius="xl" color="blue" variant="filled">
<Loader size={12} color="white" />
</ThemeIcon>
) : (
<ThemeIcon size={24} radius="xl" color="gray" variant="light">
<IconLoader size={14} />
</ThemeIcon>
)
}
>
<Group gap="xs" mt={-4}>
<step.icon size={16} color={isDone ? "var(--mantine-color-green-6)" : isActive ? "var(--mantine-color-blue-5)" : "var(--mantine-color-dimmed)"} />
<Text
size={isMigration ? "xs" : "sm"}
fw={isActive ? 600 : 400}
ff={isMigration ? "monospace" : undefined}
c={isDone ? "green" : isActive ? "white" : "dimmed"}
>
{step.label}
</Text>
</Group>
</Timeline.Item>
);
})}
</Timeline>
</Box>
</Stack>
</Paper>
);
};
@@ -1,30 +0,0 @@
export interface BackupMetadata {
homarrVersion: string;
exportedAt: string;
dbDialect: string;
encryptionKey?: string;
}
export interface MigrationFile {
idx: number;
tag: string;
sql: string;
when: number;
}
export interface MigrationStatus {
applied: number;
pending: MigrationFile[];
total: number;
}
export interface BackupAnalysis {
metadata: BackupMetadata;
counts: Record<string, number>;
boardNames: string[];
migrations: MigrationStatus;
}
export type RestoreStep = "upload" | "preview" | "confirm" | "restoring" | "error";
export const PREVIEW_TABLE_KEYS = ["board", "user", "app", "integration", "item", "media", "search_engine"] as const;
@@ -1,187 +0,0 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { useScopedI18n } from "@homarr/translation/client";
import type { BackupAnalysis, MigrationFile, MigrationStatus } from "./types";
import { PREVIEW_TABLE_KEYS } from "./types";
const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations";
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const getAppliedMigrationCount = (db: any): number => {
try {
const result = db.exec(`SELECT COUNT(*) FROM "${DRIZZLE_MIGRATIONS_TABLE}"`);
return (result[0]?.values[0]?.[0] as number) ?? 0;
} catch {
return 0;
}
};
const fetchMigrations = async (): Promise<MigrationFile[]> => {
const res = await fetch("/api/backup/migrations");
if (!res.ok) throw new Error("Failed to fetch migration files from server");
const data = await res.json();
return data.migrations ?? [];
};
const countEntities = (db: any): Record<string, number> => {
const counts: Record<string, number> = {};
for (const key of PREVIEW_TABLE_KEYS) {
try {
const result = db.exec(`SELECT COUNT(*) FROM "${key}"`);
counts[key] = (result[0]?.values[0]?.[0] as number) ?? 0;
} catch {
counts[key] = 0;
}
}
return counts;
};
const getBoardNames = (db: any): string[] => {
try {
const result = db.exec('SELECT "name" FROM "board" ORDER BY "name"');
return result[0]?.values.map((row: unknown[]) => row[0] as string) ?? [];
} catch {
return [];
}
};
export interface MigrationProgress {
current: number;
total: number;
tag: string;
phase: "pending" | "applying" | "done" | "error";
}
export const useBackupAnalysis = () => {
const t = useScopedI18n("management.page.tool.backup.restore");
const [analysis, setAnalysis] = useState<BackupAnalysis | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [migrationProgress, setMigrationProgress] = useState<MigrationProgress | null>(null);
const dbRef = useRef<any>(null);
const analyzeFile = useCallback(async (file: File) => {
setLoading(true);
setError(null);
setAnalysis(null);
setMigrationProgress(null);
try {
const [JSZip, initSqlJs, allMigrations, wasmBinary] = await Promise.all([
import("jszip").then((m) => m.default),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
import("sql.js").then((m: any) => m.default),
fetchMigrations(),
fetch("/api/backup/sql-wasm").then((r) => {
if (!r.ok) throw new Error("Failed to load SQL WASM binary");
return r.arrayBuffer();
}),
]);
const arrayBuffer = await file.arrayBuffer();
const zip = await JSZip.loadAsync(arrayBuffer);
const metadataFile = zip.file("metadata.json");
if (!metadataFile) throw new Error(t("invalidMissingMetadata"));
const dbFile = zip.file("db.sqlite");
if (!dbFile) throw new Error(t("invalidMissingDb"));
const metadata = JSON.parse(await metadataFile.async("string"));
const dbBuffer = await dbFile.async("uint8array");
const SQL = await initSqlJs({ wasmBinary });
const db = new SQL.Database(dbBuffer);
dbRef.current = db;
try {
const appliedCount = getAppliedMigrationCount(db);
const pendingMigrations = allMigrations.filter((m) => m.idx >= appliedCount);
const migrations: MigrationStatus = {
applied: appliedCount,
pending: pendingMigrations,
total: allMigrations.length,
};
if (pendingMigrations.length > 0) {
for (let i = 0; i < pendingMigrations.length; i++) {
const migration = pendingMigrations[i]!;
setMigrationProgress({
current: i + 1,
total: pendingMigrations.length,
tag: migration.tag,
phase: "applying",
});
await delay(100);
try {
const statements = migration.sql
.split("--> statement-breakpoint")
.map((s) => s.trim())
.filter(Boolean);
for (const stmt of statements) {
db.run(stmt);
}
db.run(
`INSERT INTO "${DRIZZLE_MIGRATIONS_TABLE}" ("hash", "created_at") VALUES (?, ?)`,
[migration.tag, Date.now()],
);
} catch (migrationErr) {
setMigrationProgress({
current: i + 1,
total: pendingMigrations.length,
tag: migration.tag,
phase: "error",
});
throw new Error(
`Migration ${migration.tag} failed: ${migrationErr instanceof Error ? migrationErr.message : "Unknown error"}`,
);
}
setMigrationProgress({
current: i + 1,
total: pendingMigrations.length,
tag: migration.tag,
phase: "done",
});
await delay(50);
}
}
const counts = countEntities(db);
const boardNames = getBoardNames(db);
setMigrationProgress(null);
setAnalysis({ metadata, counts, boardNames, migrations });
} finally {
db.close();
dbRef.current = null;
}
} catch (err) {
setError(err instanceof Error ? err.message : t("analyzeError"));
} finally {
setLoading(false);
}
}, [t]);
const reset = useCallback(() => {
if (dbRef.current) {
try { dbRef.current.close(); } catch { /* already closed */ }
dbRef.current = null;
}
setAnalysis(null);
setError(null);
setMigrationProgress(null);
}, []);
return { analysis, loading, error, migrationProgress, analyzeFile, reset };
};
@@ -19,6 +19,10 @@ import { BoardItemMenu } from "./item-menu";
interface BoardItemContentProps {
item: SectionItem;
fallbackDimensions?: {
width: number;
height: number;
};
}
const getOverflowFromKind = (kind: SectionItem["kind"]) => {
@@ -27,9 +31,11 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => {
return undefined;
};
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
export const BoardItemContent = ({ item, fallbackDimensions }: BoardItemContentProps) => {
const { ref, width, height } = useElementSize<HTMLDivElement>();
const board = useRequiredBoard();
const fallbackWidth = fallbackDimensions?.width ?? 0;
const fallbackHeight = fallbackDimensions?.height ?? 0;
return (
<>
@@ -52,7 +58,7 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
}}
p={0}
>
<InnerContent item={item} width={width} height={height} />
<InnerContent item={item} width={width || fallbackWidth} height={height || fallbackHeight} />
</Card>
{item.advancedOptions.title?.trim() && (
<Badge
@@ -12,7 +12,7 @@ import { widgetImports } from "@homarr/widgets";
import { WidgetEditModal } from "@homarr/widgets/modals";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { useSectionContext } from "../sections/section-context";
import { useOptionalSectionContext } from "../sections/section-context";
import { useItemActions } from "./item-actions";
import { ItemMoveModal } from "./item-move-modal";
@@ -41,7 +41,8 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
useItemActions();
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
const { gridstack } = useSectionContext().refs;
const sectionContext = useOptionalSectionContext();
const gridstack = sectionContext?.refs.gridstack;
const settings = useSettings();
// Reset error boundary on next render if item has been edited
@@ -85,7 +86,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) {
@@ -128,7 +128,7 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
<Menu.Item
leftSection={<IconLayoutKanban size={16} />}
onClick={() => {
if (!gridstack.current) return;
if (!gridstack?.current) return;
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
}}
>
@@ -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,48 +1,21 @@
import { Card, Collapse, Group, Stack, Title, UnstyledButton } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import type { CategorySection } from "~/app/[locale]/boards/_types";
import { CategoryMenu } from "./category/category-menu";
import { GridStack } from "./gridstack/gridstack";
import classes from "./item.module.css";
const localStorageKey = (sectionId: string) => `homarr-section-collapsed-${sectionId}`;
import { useCategoryCollapse } from "./use-category-collapse";
interface Props {
section: CategorySection;
}
export const BoardCategorySection = ({ section }: Props) => {
const { status } = useSession();
const { mutate } = clientApi.section.changeCollapsed.useMutation();
const board = useRequiredBoard();
const initialCollapsed =
typeof window !== "undefined"
? (localStorage.getItem(localStorageKey(section.id)) ?? String(section.collapsed)) === "true"
: section.collapsed;
const [opened, { toggle }] = useDisclosure(initialCollapsed, {
onOpen() {
if (status === "authenticated") {
mutate({ sectionId: section.id, collapsed: true });
} else if (status === "unauthenticated") {
localStorage.setItem(localStorageKey(section.id), "true");
}
},
onClose() {
if (status === "authenticated") {
mutate({ sectionId: section.id, collapsed: false });
} else if (status === "unauthenticated") {
localStorage.setItem(localStorageKey(section.id), "false");
}
},
});
const [opened, { toggle }] = useCategoryCollapse(section);
return (
<Card style={{ "--opacity": board.opacity / 100 }} radius={board.itemRadius} p={0} className={classes.itemCard}>
@@ -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",
});
@@ -20,4 +20,8 @@ export const useSectionContext = () => {
return context;
};
export const useOptionalSectionContext = () => {
return useContext(SectionContext);
};
export const SectionProvider = SectionContext.Provider;
@@ -0,0 +1,93 @@
import { describe, expect, test } from "vitest";
import { BoardMockBuilder } from "../items/actions/test/mocks/board-mock";
import { DynamicSectionMockBuilder } from "../items/actions/test/mocks/dynamic-section-mock";
import { ItemMockBuilder } from "../items/actions/test/mocks/item-mock";
import { LayoutMockBuilder } from "../items/actions/test/mocks/layout-mock";
import {
getEstimatedGridWidth,
getFallbackDimensions,
getFullWidthSortedSections,
getStaticGridElements,
getStaticGridLayouts,
getStaticGridRowCount,
getStaticLayoutSelectionCss,
} from "./static-grid-layout";
describe("static grid layout", () => {
test("sorts layouts and creates breakpoint CSS in mobile-first order", () => {
const mobile = new LayoutMockBuilder({ id: "mobile", breakpoint: 0, columnCount: 4 }).build();
const tablet = new LayoutMockBuilder({ id: "tablet", breakpoint: 768, columnCount: 8 }).build();
const desktop = new LayoutMockBuilder({ id: "desktop", breakpoint: 1024, columnCount: 12 }).build();
const sortedLayouts = getStaticGridLayouts([desktop, mobile, tablet]);
const css = getStaticLayoutSelectionCss("board-1", sortedLayouts);
expect(sortedLayouts.map((layout) => layout.id)).toEqual(["mobile", "tablet", "desktop"]);
expect(css).toContain('[data-static-layout="mobile"] { display: block; }');
expect(css).toContain("@media (min-width: 768px)");
expect(css).toContain('[data-static-layout="tablet"] { display: block; }');
expect(css).toContain("@media (min-width: 1024px)");
expect(css).toContain('[data-static-layout="desktop"] { display: block; }');
});
test("selects and sorts layout-specific section elements", () => {
const rootSectionId = "root";
const mobileLayoutId = "mobile";
const desktopLayoutId = "desktop";
const mobile = new LayoutMockBuilder({ id: mobileLayoutId, breakpoint: 0, columnCount: 4 }).build();
const desktop = new LayoutMockBuilder({ id: desktopLayoutId, breakpoint: 1024, columnCount: 12 }).build();
const item = new ItemMockBuilder({ id: "item" })
.addLayout({ layoutId: mobileLayoutId, sectionId: rootSectionId, xOffset: 2, yOffset: 1, width: 1, height: 1 })
.addLayout({ layoutId: desktopLayoutId, sectionId: rootSectionId, xOffset: 0, yOffset: 1, width: 4, height: 2 })
.build();
const dynamicSection = new DynamicSectionMockBuilder({ id: "dynamic" })
.addLayout({
layoutId: mobileLayoutId,
parentSectionId: rootSectionId,
xOffset: 0,
yOffset: 0,
width: 2,
height: 2,
})
.addLayout({
layoutId: desktopLayoutId,
parentSectionId: rootSectionId,
xOffset: 4,
yOffset: 1,
width: 4,
height: 2,
})
.build();
const board = new BoardMockBuilder()
.addEmptySection({ id: rootSectionId, yOffset: 2 })
.addEmptySection({ id: "first", yOffset: 0 })
.addSection(dynamicSection)
.addItem(item)
.build();
board.layouts = [desktop, mobile];
const mobileElements = getStaticGridElements(board, rootSectionId, mobileLayoutId);
const desktopElements = getStaticGridElements(board, rootSectionId, desktopLayoutId);
const fullWidthSections = getFullWidthSortedSections(board);
expect(mobileElements.map((element) => element.id)).toEqual(["dynamic", "item"]);
expect(desktopElements.map((element) => ({ id: element.id, width: element.width }))).toEqual([
{ id: "item", width: 4 },
{ id: "dynamic", width: 4 },
]);
expect(getStaticGridRowCount(mobileElements)).toBe(2);
expect(fullWidthSections.map((section) => section.id)).toEqual(["first", rootSectionId]);
});
test("estimates grid and item dimensions per layout range", () => {
const mobile = new LayoutMockBuilder({ id: "mobile", breakpoint: 0, columnCount: 4 }).build();
const tablet = new LayoutMockBuilder({ id: "tablet", breakpoint: 768, columnCount: 8 }).build();
const desktop = new LayoutMockBuilder({ id: "desktop", breakpoint: 1024, columnCount: 12 }).build();
expect(getEstimatedGridWidth(mobile, tablet)).toBe(384);
expect(getEstimatedGridWidth(tablet, desktop)).toBe(896);
expect(getEstimatedGridWidth(desktop)).toBe(1280);
expect(getFallbackDimensions({ width: 2, height: 3 }, 400, 4)).toEqual({ width: 180, height: 280 });
});
});
@@ -0,0 +1,103 @@
import type { Board, DynamicSectionItem, EmptySection, SectionItem } from "~/app/[locale]/boards/_types";
type BoardLayout = Board["layouts"][number];
type StaticGridElement = SectionItem | DynamicSectionItem;
const STATIC_GRID_ITEM_INSET = 20;
const DEFAULT_SMALL_VIEWPORT_WIDTH = 360;
const DEFAULT_LARGE_VIEWPORT_WIDTH = 1280;
export const getStaticGridLayouts = (layouts: Board["layouts"]) =>
[...layouts].toSorted((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint);
const escapeCssString = (value: string) => value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
export const getStaticLayoutSelectionCss = (boardId: string, layouts: Board["layouts"]) => {
const sortedLayouts = getStaticGridLayouts(layouts);
const initialLayout = sortedLayouts.at(0);
if (!initialLayout) return "";
const scope = `[data-static-board="${escapeCssString(boardId)}"]`;
const css = [
`${scope} .static-board-layout { display: none; }`,
`${scope} .static-board-layout[data-static-layout="${escapeCssString(initialLayout.id)}"] { display: block; }`,
];
for (const layout of sortedLayouts.slice(1)) {
css.push(
`@media (min-width: ${layout.breakpoint}px) { ${scope} .static-board-layout { display: none; } ${scope} .static-board-layout[data-static-layout="${escapeCssString(layout.id)}"] { display: block; } }`,
);
}
return css.join("\n");
};
export const getFullWidthSortedSections = (board: Board) =>
board.sections
.filter(
(section): section is EmptySection | Extract<Board["sections"][number], { kind: "category" }> =>
section.kind === "empty" || section.kind === "category",
)
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
export const getSectionItemsForLayout = (board: Board, sectionId: string, layoutId: string): SectionItem[] => {
return board.items
.map(({ layouts, ...item }) => {
const layout = layouts.find((candidate) => candidate.layoutId === layoutId);
if (!layout) return null;
return { ...layout, ...item, type: "item" as const };
})
.filter((item): item is SectionItem => item !== null && item.sectionId === sectionId);
};
export const getDynamicSectionsForLayout = (
board: Board,
sectionId: string,
layoutId: string,
): DynamicSectionItem[] => {
return board.sections
.filter((section) => section.kind === "dynamic")
.map(({ layouts, ...section }) => {
const layout = layouts.find((candidate) => candidate.layoutId === layoutId);
if (!layout) return null;
return { ...layout, ...section, type: "section" as const };
})
.filter((entry): entry is DynamicSectionItem => entry !== null && entry.parentSectionId === sectionId);
};
export const getStaticGridElements = (board: Board, sectionId: string, layoutId: string): StaticGridElement[] => {
return [
...getSectionItemsForLayout(board, sectionId, layoutId),
...getDynamicSectionsForLayout(board, sectionId, layoutId),
].toSorted((itemA, itemB) => {
if (itemA.yOffset === itemB.yOffset) return itemA.xOffset - itemB.xOffset;
return itemA.yOffset - itemB.yOffset;
});
};
export const getStaticGridRowCount = (items: StaticGridElement[], fixedRowCount?: number) => {
return fixedRowCount ?? Math.max(...items.map((item) => item.yOffset + item.height));
};
export const getEstimatedGridWidth = (layout: BoardLayout, nextLayout?: BoardLayout) => {
if (nextLayout) {
const upperBound = Math.max(nextLayout.breakpoint - 1, DEFAULT_SMALL_VIEWPORT_WIDTH);
return Math.max(DEFAULT_SMALL_VIEWPORT_WIDTH, Math.round((layout.breakpoint + upperBound) / 2));
}
return Math.max(layout.breakpoint, DEFAULT_LARGE_VIEWPORT_WIDTH);
};
export const getFallbackDimensions = (
item: Pick<StaticGridElement, "width" | "height">,
estimatedGridWidth: number,
columnCount: number,
) => {
const cellSize = estimatedGridWidth / columnCount;
return {
width: Math.max(0, item.width * cellSize - STATIC_GRID_ITEM_INSET),
height: Math.max(0, item.height * cellSize - STATIC_GRID_ITEM_INSET),
};
};
@@ -0,0 +1,289 @@
"use client";
import { Badge, Card, Collapse, Group, Stack, Title, UnstyledButton } from "@mantine/core";
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
import { useRequiredBoard } from "@homarr/boards/context";
import type {
Board,
CategorySection,
DynamicSectionItem,
EmptySection,
SectionItem,
} from "~/app/[locale]/boards/_types";
import { BoardItemContent } from "../items/item-content";
import { CategoryMenu } from "./category/category-menu";
import classes from "./item.module.css";
import {
getEstimatedGridWidth,
getFallbackDimensions,
getFullWidthSortedSections,
getStaticGridElements,
getStaticGridLayouts,
getStaticGridRowCount,
getStaticLayoutSelectionCss,
} from "./static-grid-layout";
import { useCategoryCollapse } from "./use-category-collapse";
interface StaticBoardGridProps {
board: Board;
}
export const StaticBoardGrid = ({ board }: StaticBoardGridProps) => {
const boardId = `board-${board.id}`;
const layouts = getStaticGridLayouts(board.layouts);
const layoutSelectionCss = getStaticLayoutSelectionCss(boardId, layouts);
const fullWidthSortedSections = getFullWidthSortedSections(board);
if (layouts.length === 0) return null;
return (
<>
<style>{layoutSelectionCss}</style>
<div data-static-board={boardId}>
{layouts.map((layout, index) => (
<div key={layout.id} className="static-board-layout" data-static-layout={layout.id}>
<Stack h="100%">
{fullWidthSortedSections.map((section) =>
section.kind === "empty" ? (
<StaticEmptySection
key={section.id}
section={section}
board={board}
layoutId={layout.id}
columnCount={layout.columnCount}
estimatedGridWidth={getEstimatedGridWidth(layout, layouts.at(index + 1))}
/>
) : (
<StaticCategorySection
key={section.id}
section={section}
board={board}
layoutId={layout.id}
columnCount={layout.columnCount}
estimatedGridWidth={getEstimatedGridWidth(layout, layouts.at(index + 1))}
/>
),
)}
</Stack>
</div>
))}
</div>
</>
);
};
interface StaticSectionGridProps {
sectionId: string;
board: Board;
layoutId: string;
columnCount: number;
estimatedGridWidth: number;
isDynamic?: boolean;
fixedRowCount?: number;
}
const StaticSectionGrid = ({
sectionId,
board,
layoutId,
columnCount,
estimatedGridWidth,
isDynamic,
fixedRowCount,
}: StaticSectionGridProps) => {
const allItems = getStaticGridElements(board, sectionId, layoutId);
if (allItems.length === 0) return null;
const rowCount = getStaticGridRowCount(allItems, fixedRowCount);
return (
<div className="static-grid-container" style={{ height: isDynamic ? "100%" : undefined }}>
<div
className="static-grid"
style={{
display: "grid",
gridTemplateColumns: `repeat(${columnCount}, 1fr)`,
gridTemplateRows: isDynamic
? `repeat(${rowCount}, 1fr)`
: `repeat(${rowCount}, calc(100cqi / ${columnCount}))`,
height: isDynamic ? "100%" : undefined,
}}
>
{allItems.map((item) => (
<StaticGridItem
key={item.id}
item={item}
board={board}
layoutId={layoutId}
estimatedGridWidth={estimatedGridWidth}
columnCount={columnCount}
/>
))}
</div>
</div>
);
};
interface StaticGridItemProps {
item: SectionItem | DynamicSectionItem;
board: Board;
layoutId: string;
estimatedGridWidth: number;
columnCount: number;
}
const StaticGridItem = ({ item, board, layoutId, estimatedGridWidth, columnCount }: StaticGridItemProps) => {
const fallbackDimensions = getFallbackDimensions(item, estimatedGridWidth, columnCount);
return (
<div
style={{
gridColumn: `${item.xOffset + 1} / span ${item.width}`,
gridRow: `${item.yOffset + 1} / span ${item.height}`,
position: "relative",
}}
>
<div className="static-grid-item-content">
{item.type === "item" ? (
<BoardItemContent item={item} fallbackDimensions={fallbackDimensions} />
) : (
<StaticDynamicSection
section={item}
board={board}
layoutId={layoutId}
estimatedGridWidth={fallbackDimensions.width}
/>
)}
</div>
</div>
);
};
interface StaticEmptySectionProps {
section: EmptySection;
board: Board;
layoutId: string;
columnCount: number;
estimatedGridWidth: number;
}
const StaticEmptySection = ({ section, board, layoutId, columnCount, estimatedGridWidth }: StaticEmptySectionProps) => {
const items = getStaticGridElements(board, section.id, layoutId);
if (items.length === 0) return null;
return (
<StaticSectionGrid
sectionId={section.id}
board={board}
layoutId={layoutId}
columnCount={columnCount}
estimatedGridWidth={estimatedGridWidth}
/>
);
};
interface StaticCategorySectionProps {
section: CategorySection;
board: Board;
layoutId: string;
columnCount: number;
estimatedGridWidth: number;
}
const StaticCategorySection = ({
section,
board,
layoutId,
columnCount,
estimatedGridWidth,
}: StaticCategorySectionProps) => {
const boardData = useRequiredBoard();
const [opened, { toggle }] = useCategoryCollapse(section);
return (
<Card
style={{ "--opacity": boardData.opacity / 100 }}
radius={boardData.itemRadius}
p={0}
className={classes.itemCard}
>
<Stack>
<Group wrap="nowrap" gap="sm">
<UnstyledButton w="100%" p="sm" onClick={toggle}>
<Group wrap="nowrap">
{opened ? <IconChevronUp size={20} /> : <IconChevronDown size={20} />}
<Title order={3}>{section.name}</Title>
</Group>
</UnstyledButton>
<CategoryMenu category={section} />
</Group>
<Collapse expanded={opened} p="sm" pt={0}>
<StaticSectionGrid
sectionId={section.id}
board={board}
layoutId={layoutId}
columnCount={columnCount}
estimatedGridWidth={estimatedGridWidth}
/>
</Collapse>
</Stack>
</Card>
);
};
interface StaticDynamicSectionProps {
section: DynamicSectionItem;
board: Board;
layoutId: string;
estimatedGridWidth: number;
}
const StaticDynamicSection = ({ section, board, layoutId, estimatedGridWidth }: StaticDynamicSectionProps) => {
const boardData = useRequiredBoard();
const options = section.options;
return (
<Card
className={classes.itemCard}
w="100%"
h="100%"
styles={{
root: {
overflow: "visible",
"--opacity": boardData.opacity / 100,
"--border-color": options.borderColor || undefined,
},
}}
radius={boardData.itemRadius}
p={0}
>
{options.title && (
<Badge
pos="absolute"
top={-15}
left={10}
size="md"
radius={boardData.itemRadius}
color="var(--background-color)"
c="var(--mantine-color-text)"
bd="1px solid var(--border-color)"
>
{options.title}
</Badge>
)}
<StaticSectionGrid
sectionId={section.id}
board={board}
layoutId={layoutId}
columnCount={section.width}
estimatedGridWidth={estimatedGridWidth}
isDynamic
fixedRowCount={section.height}
/>
</Card>
);
};
@@ -0,0 +1,77 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import type { CategorySection } from "~/app/[locale]/boards/_types";
export const getCategoryCollapseStorageKey = (sectionId: string) => `homarr-section-collapsed-${sectionId}`;
const categoryCollapseChangeEventName = "homarr-section-collapse-change";
interface CategoryExpandedChangeEventDetail {
sectionId: string;
expanded: boolean;
}
const getInitialExpanded = (section: CategorySection) => {
if (typeof window === "undefined") return section.collapsed;
return (localStorage.getItem(getCategoryCollapseStorageKey(section.id)) ?? String(section.collapsed)) === "true";
};
export const useCategoryCollapse = (section: CategorySection) => {
const { status } = useSession();
const { mutate } = clientApi.section.changeCollapsed.useMutation();
const [opened, setOpened] = useState(() => getInitialExpanded(section));
const persistExpanded = useCallback(
(expanded: boolean) => {
if (status === "authenticated") {
mutate({ sectionId: section.id, collapsed: expanded });
} else if (status === "unauthenticated") {
localStorage.setItem(getCategoryCollapseStorageKey(section.id), String(expanded));
}
},
[mutate, section.id, status],
);
useEffect(() => {
const handleCollapseChange = (event: Event) => {
const { sectionId, expanded } = (event as CustomEvent<CategoryExpandedChangeEventDetail>).detail;
if (sectionId === section.id) {
setOpened(expanded);
}
};
window.addEventListener(categoryCollapseChangeEventName, handleCollapseChange);
return () => {
window.removeEventListener(categoryCollapseChangeEventName, handleCollapseChange);
};
}, [section.id]);
const toggle = useCallback(() => {
setOpened((current) => {
const expanded = !current;
persistExpanded(expanded);
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent<CategoryExpandedChangeEventDetail>(categoryCollapseChangeEventName, {
detail: {
sectionId: section.id,
expanded,
},
}),
);
}
return expanded;
});
}, [persistExpanded, section.id]);
return [opened, { toggle }] as const;
};
@@ -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,
});
+2 -7
View File
@@ -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]);
-1
View File
@@ -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,
});
-1
View File
@@ -2,4 +2,3 @@ declare module "*.png";
declare module "*.svg";
declare module "*.jpeg";
declare module "*.jpg";
declare module "sql.js";
+14
View File
@@ -112,3 +112,17 @@
.grid-stack-empty-wrapper {
display: none !important;
}
.static-grid-container {
container-type: inline-size;
}
.static-grid-item-content {
position: absolute;
inset: 10px;
> .grid-stack-item-content {
width: 100%;
height: 100%;
}
}
+10 -20
View File
@@ -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/>
Loaded 100 of 275 files, more files were not shown because too many files have changed in this diff. Show more