Compare commits

..
392 changed files with 7291 additions and 35777 deletions

No files matched your search

+3 -4
View File
@@ -28,8 +28,7 @@ DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE'
# DB_PASSWORD='password'
# DB_NAME='name-of-database'
# The below path can be used to store trusted certificates during development, it is not required and can be left empty.
# If it is used, please use the full path to the directory where the certificates are stored.
# LOCAL_CERTIFICATE_PATH='FULL_PATH_TO_CERTIFICATES'
TURBO_TELEMETRY_DISABLED=1
TURBO_TELEMETRY_DISABLED=1
# Configure logging to use winston logger
NODE_OPTIONS='-r @homarr/log/override'
+1 -9
View File
@@ -31,14 +31,6 @@ body:
label: Version
description: What version of Homarr are you running?
options:
- 1.5.0
- 1.4.0
- 1.3.1
- 1.3.0
- 1.2.0
- 1.1.0
- 1.0.1
- 1.0.0
- 1.0.0-beta
- Other (describe in "additional information")
default: 0
@@ -83,4 +75,4 @@ body:
- Other (describe in "additional information")
default: 0
validations:
required: false
required: false
-5
View File
@@ -6,11 +6,6 @@
matchPackagePatterns: ["^@homarr/"],
enabled: false,
},
// Disable Dockerode updates see https://github.com/apocas/dockerode/issues/787
{
matchPackagePatterns: ["^dockerode$"],
enabled: false,
},
{
matchUpdateTypes: ["minor", "patch", "pin", "digest"],
automerge: true,
@@ -37,7 +37,7 @@ jobs:
token: ${{ github.token }}
branch: dev
- name: Create pull request
run: 'gh pr create --title "chore(release): automatic release ${{ steps.semver.outputs.next }}" --body "**This is an automatic release**.<br/>Manual action may be required for major bumps.<br/>Detected change to be ``${{ steps.semver.outputs.bump }}``<br/>Bump version from ``${{ steps.semver.outputs.current }}`` to ``${{ steps.semver.outputs.next }}``" --base main --head dev --label automerge'
run: 'gh pr create --title "chore(release): automatic release ${{ steps.semver.outputs.next }}" --body "**This is an automatic release**.<br/>Manual action may be required for major bumps.<br/>Detected change to be ``${{ steps.semver.outputs.bump }}``<br/>Bump version from ``${{ steps.semver.outputs.current }}`` to ``${{ steps.semver.outputs.next }}``" --base beta --head dev --label automerge'
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: Discord notification
+1 -1
View File
@@ -64,4 +64,4 @@ packages/cli/cli.cjs
e2e/shared/tmp
#personal backgrounds
apps/nextjs/public/images/background.png
apps/nextjs/public/images/background.png
+1 -1
View File
@@ -1 +1 @@
22.14.0
22.13.0
+1 -1331
View File
File diff suppressed because it is too large. Load diff
+2 -7
View File
@@ -1,4 +1,4 @@
FROM node:22.14.0-alpine AS base
FROM node:22.13.0-alpine AS base
FROM base AS builder
RUN apk add --no-cache libc6-compat
@@ -10,8 +10,6 @@ RUN apk add --no-cache libc6-compat curl bash
RUN apk update
COPY . .
# Install working version of corepack (See https://github.com/nodejs/corepack/issues/612)
RUN npm install -g corepack@0.31.0 && corepack --version
RUN corepack enable pnpm && pnpm install --recursive --frozen-lockfile
# Copy static data as it is not part of the build
@@ -19,8 +17,6 @@ COPY static-data ./static-data
ARG SKIP_ENV_VALIDATION='true'
ARG CI='true'
ARG DISABLE_REDIS_LOGS='true'
# Install working version of corepack (See https://github.com/nodejs/corepack/issues/612)
RUN npm install -g corepack@0.31.0 && corepack --version
RUN corepack enable pnpm && pnpm build
FROM base AS runner
@@ -67,7 +63,6 @@ ENV DB_URL='/appdata/db/db.sqlite'
ENV DB_DIALECT='sqlite'
ENV DB_DRIVER='better-sqlite3'
ENV AUTH_PROVIDERS='credentials'
ENV NODE_ENV='production'
ENTRYPOINT [ "/app/entrypoint.sh" ]
CMD ["sh", "run.sh"]
CMD ["sh", "run.sh"]
-1
View File
@@ -2,7 +2,6 @@
import "@homarr/auth/env";
import "@homarr/db/env";
import "@homarr/common/env";
import "@homarr/docker/env";
import type { NextConfig } from "next";
import MillionLint from "@million/lint";
+23 -29
View File
@@ -18,16 +18,12 @@
"@homarr/analytics": "workspace:^0.1.0",
"@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0",
"@homarr/boards": "workspace:^0.1.0",
"@homarr/certificates": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/cron-job-status": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/docker": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0",
"@homarr/gridstack": "^1.12.0",
"@homarr/icons": "workspace:^0.1.0",
"@homarr/gridstack": "^1.11.3",
"@homarr/integrations": "workspace:^0.1.0",
"@homarr/log": "workspace:^",
"@homarr/modals": "workspace:^0.1.0",
@@ -37,24 +33,23 @@
"@homarr/old-schema": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@homarr/settings": "workspace:^0.1.0",
"@homarr/spotlight": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "^7.16.3",
"@mantine/core": "^7.16.3",
"@mantine/dropzone": "^7.16.3",
"@mantine/hooks": "^7.16.3",
"@mantine/modals": "^7.16.3",
"@mantine/tiptap": "^7.16.3",
"@mantine/colors-generator": "^7.16.0",
"@mantine/core": "^7.16.0",
"@mantine/dropzone": "^7.16.0",
"@mantine/hooks": "^7.16.0",
"@mantine/modals": "^7.16.0",
"@mantine/tiptap": "^7.16.0",
"@million/lint": "1.0.14",
"@t3-oss/env-nextjs": "^0.12.0",
"@tabler/icons-react": "^3.30.0",
"@tanstack/react-query": "^5.66.0",
"@tanstack/react-query-devtools": "^5.66.0",
"@tanstack/react-query-next-experimental": "^5.66.0",
"@t3-oss/env-nextjs": "^0.11.1",
"@tabler/icons-react": "^3.28.1",
"@tanstack/react-query": "^5.64.1",
"@tanstack/react-query-devtools": "^5.64.1",
"@tanstack/react-query-next-experimental": "5.64.1",
"@trpc/client": "next",
"@trpc/next": "next",
"@trpc/react-query": "next",
@@ -66,35 +61,34 @@
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"dotenv": "^16.4.7",
"flag-icons": "^7.3.2",
"flag-icons": "^7.2.3",
"glob": "^11.0.1",
"jotai": "^2.12.0",
"jotai": "^2.11.0",
"mantine-react-table": "2.0.0-beta.8",
"next": "15.1.7",
"next": "15.1.4",
"postcss-preset-mantine": "^1.17.0",
"prismjs": "^1.29.0",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-error-boundary": "^5.0.0",
"react-simple-code-editor": "^0.14.1",
"sass": "^1.85.0",
"sass": "^1.83.4",
"superjson": "2.2.2",
"swagger-ui-react": "^5.18.3",
"use-deep-compare-effect": "^1.8.1",
"zod": "^3.24.2"
"swagger-ui-react": "^5.18.2",
"use-deep-compare-effect": "^1.8.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "3.1.1",
"@types/node": "^22.13.4",
"@types/chroma-js": "3.1.0",
"@types/node": "^22.10.6",
"@types/prismjs": "^1.26.5",
"@types/react": "19.0.8",
"@types/react": "19.0.7",
"@types/react-dom": "19.0.3",
"@types/swagger-ui-react": "^5.18.0",
"@types/swagger-ui-react": "^4.18.3",
"concurrently": "^9.1.2",
"eslint": "^9.20.1",
"eslint": "^9.18.0",
"node-loader": "^2.1.0",
"prettier": "^3.4.2",
"typescript": "^5.7.3"
@@ -18,8 +18,8 @@ import superjson from "superjson";
import type { SuperJSONResult } from "superjson";
import type { AppRouter } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
import { clientApi, getTrpcUrl } from "@homarr/api/client";
import { createHeadersCallbackForSource } from "@homarr/api/shared";
import { env } from "~/env";
@@ -94,7 +94,6 @@ export function TRPCReactProvider(props: PropsWithChildren) {
false: unstable_httpBatchStreamLink({
transformer: superjson,
url: getTrpcUrl(),
maxURLLength: 2083, // Suggested by tRPC: https://trpc.io/docs/client/links/httpBatchLink#setting-a-maximum-url-length
headers: createHeadersCallbackForSource("nextjs-react (json)"),
}),
}),
@@ -2,13 +2,13 @@
import { useRouter } from "next/navigation";
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
import type { z } from "zod";
import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { CustomPasswordInput } from "@homarr/ui";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
interface RegistrationFormProps {
@@ -5,7 +5,6 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Anchor, Button, Card, Code, Collapse, Divider, PasswordInput, Stack, Text, TextInput } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { z } from "zod";
import { signIn } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
@@ -13,7 +12,7 @@ import type { useForm } from "@homarr/form";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
import { validation, z } from "@homarr/validation";
interface LoginFormProps {
providers: string[];
@@ -5,13 +5,12 @@ import { Box, LoadingOverlay, Stack } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { BoardCategorySection } from "~/components/board/sections/category-section";
import { BoardEmptySection } from "~/components/board/sections/empty-section";
import { BoardBackgroundVideo } from "~/components/layout/background";
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
import { useIsBoardReady } from "./_ready-context";
import { useIsBoardReady, useRequiredBoard } from "./_context";
let boardName: string | null = null;
@@ -0,0 +1,118 @@
"use client";
import type { Dispatch, PropsWithChildren, SetStateAction } from "react";
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { updateBoardName } from "./_client";
const BoardContext = createContext<{
board: RouterOutputs["board"]["getHomeBoard"];
isReady: boolean;
markAsReady: (id: string) => void;
isEditMode: boolean;
setEditMode: Dispatch<SetStateAction<boolean>>;
} | null>(null);
export const BoardProvider = ({
children,
initialBoard,
}: PropsWithChildren<{
initialBoard: RouterOutputs["board"]["getBoardByName"];
}>) => {
const pathname = usePathname();
const utils = clientApi.useUtils();
const [readySections, setReadySections] = useState<string[]>([]);
const [isEditMode, setEditMode] = useState(false);
const { data } = clientApi.board.getBoardByName.useQuery(
{ name: initialBoard.name },
{
initialData: initialBoard,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
);
// Update the board name so it can be used within updateBoard method
updateBoardName(initialBoard.name);
// Invalidate the board when the pathname changes
// This allows to refetch the board when it might have changed - e.g. if someone else added an item
useEffect(() => {
return () => {
setReadySections([]);
void utils.board.getBoardByName.invalidate({ name: initialBoard.name });
};
}, [pathname, utils, initialBoard.name]);
useEffect(() => {
setReadySections((previous) => previous.filter((id) => data.sections.some((section) => section.id === id)));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data.sections.length, setReadySections]);
const markAsReady = useCallback((id: string) => {
setReadySections((previous) => (previous.includes(id) ? previous : [...previous, id]));
}, []);
return (
<BoardContext.Provider
value={{
board: data,
isReady: data.sections.length === readySections.length,
markAsReady,
isEditMode,
setEditMode,
}}
>
{children}
</BoardContext.Provider>
);
};
export const useMarkSectionAsReady = () => {
const context = useContext(BoardContext);
if (!context) {
throw new Error("Board is required");
}
return context.markAsReady;
};
export const useIsBoardReady = () => {
const context = useContext(BoardContext);
if (!context) {
throw new Error("Board is required");
}
return context.isReady;
};
export const useRequiredBoard = () => {
const optionalBoard = useOptionalBoard();
if (!optionalBoard) {
throw new Error("Board is required");
}
return optionalBoard;
};
export const useOptionalBoard = () => {
const context = useContext(BoardContext);
return context?.board;
};
export const useEditMode = () => {
const context = useContext(BoardContext);
if (!context) {
throw new Error("Board is required");
}
return [context.isEditMode, context.setEditMode] as const;
};
@@ -7,7 +7,6 @@ import "~/styles/gridstack.scss";
import { IntegrationProvider } from "@homarr/auth/client";
import { auth } from "@homarr/auth/next";
import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server";
import { isNullOrWhitespace } from "@homarr/common";
import { getI18n } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata";
@@ -49,13 +48,11 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
return {
title: board.metaTitle ?? createMetaTitle(t("board.content.metaTitle", { boardName: board.name })),
icons: {
icon: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined,
apple: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined,
icon: board.faviconImageUrl ? board.faviconImageUrl : undefined,
apple: board.faviconImageUrl ? board.faviconImageUrl : undefined,
},
appleWebApp: {
startupImage: {
url: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : "/logo/logo.png",
},
startupImage: { url: board.faviconImageUrl ? board.faviconImageUrl : "/logo/logo.png" },
},
};
} catch (error) {
@@ -1,6 +1,6 @@
"use client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useRequiredBoard } from "./_context";
export const CustomCss = () => {
const board = useRequiredBoard();
@@ -2,7 +2,6 @@
import type { MouseEvent } from "react";
import { useCallback, useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Group, Menu } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks";
@@ -10,18 +9,14 @@ import {
IconBox,
IconBoxAlignTop,
IconChevronDown,
IconLayoutBoard,
IconPencil,
IconPencilOff,
IconPlus,
IconReplace,
IconResize,
IconSettings,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -34,6 +29,7 @@ import { CategoryEditModal } from "~/components/board/sections/category/category
import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dynamic-actions";
import { HeaderButton } from "~/components/layout/header/button";
import { env } from "~/env";
import { useEditMode, useRequiredBoard } from "./_context";
export const BoardContentHeaderActions = () => {
const [isEditMode] = useEditMode();
@@ -53,8 +49,6 @@ export const BoardContentHeaderActions = () => {
<HeaderButton href={`/boards/${board.name}/settings`}>
<IconSettings stroke={1.5} />
</HeaderButton>
<SelectBoardsMenu />
</>
);
};
@@ -120,7 +114,7 @@ const AddMenu = () => {
};
const EditModeMenu = () => {
const [isEditMode, { open, close }] = useEditMode();
const [isEditMode, setEditMode] = useEditMode();
const board = useRequiredBoard();
const utils = clientApi.useUtils();
const t = useScopedI18n("board.action.edit");
@@ -132,7 +126,7 @@ const EditModeMenu = () => {
});
void utils.board.getBoardByName.invalidate({ name: board.name });
void revalidatePathActionAsync(`/boards/${board.name}`);
close();
setEditMode(false);
},
onError() {
showErrorNotification({
@@ -144,8 +138,8 @@ const EditModeMenu = () => {
const toggle = useCallback(() => {
if (isEditMode) return saveBoard(board);
open();
}, [board, isEditMode, saveBoard, open]);
setEditMode(true);
}, [board, isEditMode, saveBoard, setEditMode]);
useHotkeys([["mod+e", toggle]]);
usePreventLeaveWithDirty(isEditMode);
@@ -157,32 +151,6 @@ const EditModeMenu = () => {
);
};
const SelectBoardsMenu = () => {
const { data: boards = [] } = clientApi.board.getAllBoards.useQuery();
return (
<Menu position="bottom-end" withArrow>
<Menu.Target>
<HeaderButton w="auto" px={4}>
<IconReplace stroke={1.5} />
</HeaderButton>
</Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
{boards.map((board) => (
<Menu.Item
key={board.id}
component={Link}
href={`/boards/${board.name}`}
leftSection={<IconLayoutBoard size={20} />}
>
{board.name}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
);
};
const usePreventLeaveWithDirty = (isDirty: boolean) => {
const t = useI18n();
const { openConfirmModal } = useConfirmModal();
@@ -1,67 +0,0 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
const BoardReadyContext = createContext<{
isReady: boolean;
markAsReady: (id: string) => void;
} | null>(null);
export const BoardReadyProvider = ({ children }: PropsWithChildren) => {
const pathname = usePathname();
const utils = clientApi.useUtils();
const board = useRequiredBoard();
const [readySections, setReadySections] = useState<string[]>([]);
// Reset sections required for ready state
useEffect(() => {
return () => {
setReadySections([]);
};
}, [pathname, utils]);
useEffect(() => {
setReadySections((previous) => previous.filter((id) => board.sections.some((section) => section.id === id)));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [board.sections.length, setReadySections]);
const markAsReady = useCallback((id: string) => {
setReadySections((previous) => (previous.includes(id) ? previous : [...previous, id]));
}, []);
return (
<BoardReadyContext.Provider
value={{
isReady: board.sections.length === readySections.length,
markAsReady,
}}
>
{children}
</BoardReadyContext.Provider>
);
};
export const useMarkSectionAsReady = () => {
const context = useContext(BoardReadyContext);
if (!context) {
throw new Error("BoardReadyProvider is required");
}
return context.markAsReady;
};
export const useIsBoardReady = () => {
const context = useContext(BoardReadyContext);
if (!context) {
throw new Error("BoardReadyProvider is required");
}
return context.isReady;
};
@@ -4,10 +4,10 @@ import type { PropsWithChildren } from "react";
import type { MantineColorsTuple } from "@mantine/core";
import { createTheme, darken, lighten, MantineProvider } from "@mantine/core";
import { useRequiredBoard } from "@homarr/boards/context";
import type { ColorScheme } from "@homarr/definitions";
import { useColorSchemeManager } from "../../_client-providers/mantine";
import { useRequiredBoard } from "./_context";
export const BoardMantineProvider = ({
children,
@@ -1,48 +0,0 @@
"use client";
import { Button, Group, Stack, Switch } from "@mantine/core";
import { useForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared";
interface Props {
board: Board;
}
export const BehaviorSettingsContent = ({ board }: Props) => {
const t = useI18n();
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
const form = useForm({
initialValues: {
disableStatus: board.disableStatus,
},
});
return (
<form
onSubmit={form.onSubmit((values) => {
savePartialSettings({
id: board.id,
...values,
});
})}
>
<Stack>
<Switch
label={t("board.field.disableStatus.label")}
description={t("board.field.disableStatus.description")}
{...form.getInputProps("disableStatus", { type: "checkbox" })}
/>
<Group justify="end">
<Button type="submit" loading={isPending} color="teal">
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</form>
);
};
@@ -5,11 +5,11 @@ import { useRouter } from "next/navigation";
import { Button, Divider, Group, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
import { useRequiredBoard } from "../../(content)/_context";
import classes from "./danger.module.css";
export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => {
@@ -5,13 +5,13 @@ import { Button, Grid, Group, Loader, Stack, TextInput, Tooltip } from "@mantine
import { useDebouncedValue, useDocumentTitle, useFavicon } from "@mantine/hooks";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useUpdateBoard } from "@homarr/boards/updater";
import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
import { createMetaTitle } from "~/metadata";
import type { Board } from "../../_types";
import { useUpdateBoard } from "../../(content)/_client";
import { useSavePartialSettingsMutation } from "./_shared";
interface Props {
@@ -4,7 +4,6 @@ import { AccordionControl, AccordionItem, AccordionPanel, Container, Stack, Text
import {
IconAlertTriangle,
IconBrush,
IconClick,
IconFileTypeCss,
IconLayout,
IconPhoto,
@@ -24,7 +23,6 @@ import type { TablerIcon } from "@homarr/ui";
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
import { BackgroundSettingsContent } from "./_background";
import { BehaviorSettingsContent } from "./_behavior";
import { BoardAccessSettings } from "./_board-access";
import { ColorSettingsContent } from "./_colors";
import { CustomCssSettingsContent } from "./_customCss";
@@ -97,9 +95,6 @@ export default async function BoardSettingsPage(props: Props) {
<AccordionItemFor value="customCss" icon={IconFileTypeCss}>
<CustomCssSettingsContent board={board} />
</AccordionItemFor>
<AccordionItemFor value="behavior" icon={IconClick}>
<BehaviorSettingsContent board={board} />
</AccordionItemFor>
{hasFullAccess && (
<>
<AccordionItemFor value="access" icon={IconUser}>
@@ -2,9 +2,8 @@
import { IconLayoutBoard } from "@tabler/icons-react";
import { useRequiredBoard } from "@homarr/boards/context";
import { HeaderButton } from "~/components/layout/header/button";
import { useRequiredBoard } from "./(content)/_context";
export const BoardOtherHeaderActions = () => {
const board = useRequiredBoard();
@@ -3,8 +3,6 @@ import { notFound } from "next/navigation";
import { AppShellMain } from "@mantine/core";
import { TRPCError } from "@trpc/server";
import { BoardProvider } from "@homarr/boards/context";
import { EditModeProvider } from "@homarr/boards/edit-mode";
import { logger } from "@homarr/log";
import { MainHeader } from "~/components/layout/header";
@@ -12,9 +10,9 @@ import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
import { ClientShell } from "~/components/layout/shell";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
import type { Board } from "./_types";
import { BoardProvider } from "./(content)/_context";
import type { Params } from "./(content)/_creator";
import { CustomCss } from "./(content)/_custom-css";
import { BoardReadyProvider } from "./(content)/_ready-context";
import { BoardMantineProvider } from "./(content)/_theme";
interface CreateBoardLayoutProps<TParams extends Params> {
@@ -44,21 +42,17 @@ export const createBoardLayout = <TParams extends Params>({
return (
<BoardProvider initialBoard={initialBoard}>
<BoardReadyProvider>
<EditModeProvider>
<BoardMantineProvider defaultColorScheme={colorScheme}>
<CustomCss />
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
actions={headerActions}
hasNavigation={false}
/>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
</BoardMantineProvider>
</EditModeProvider>
</BoardReadyProvider>
<BoardMantineProvider defaultColorScheme={colorScheme}>
<CustomCss />
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
actions={headerActions}
hasNavigation={false}
/>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
</BoardMantineProvider>
</BoardProvider>
);
};
@@ -2,12 +2,12 @@
import { Button, Card, Stack, TextInput } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react";
import type { z } from "zod";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
export const InitGroup = () => {
@@ -3,7 +3,6 @@
import { startTransition } from "react";
import { Button, Card, Group, Stack, Switch, Text } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react";
import type { z } from "zod";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
@@ -12,6 +11,7 @@ import type { CheckboxProps } from "@homarr/form/types";
import { defaultServerSettings } from "@homarr/server-settings";
import type { TranslationObject } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
export const InitSettings = () => {
@@ -1,7 +1,6 @@
"use client";
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
import type { z } from "zod";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
@@ -9,6 +8,7 @@ import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { CustomPasswordInput } from "@homarr/ui";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
export const InitUserForm = () => {
-21
View File
@@ -9,14 +9,10 @@ import "~/styles/scroll-area.scss";
import { notFound } from "next/navigation";
import { NextIntlClientProvider } from "next-intl";
import { api } from "@homarr/api/server";
import { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next";
import { db } from "@homarr/db";
import { getServerSettingsAsync } from "@homarr/db/queries";
import { ModalProvider } from "@homarr/modals";
import { Notifications } from "@homarr/notifications";
import { SettingsProvider } from "@homarr/settings";
import { SpotlightProvider } from "@homarr/spotlight";
import type { SupportedLanguage } from "@homarr/translation";
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
@@ -77,8 +73,6 @@ export default async function Layout(props: {
}
const session = await auth();
const user = session ? await api.user.getById({ userId: session.user.id }).catch(() => null) : null;
const serverSettings = await getServerSettingsAsync(db);
const colorScheme = await getCurrentColorSchemeAsync();
const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr";
const i18nMessages = await getI18nMessages();
@@ -87,21 +81,6 @@ export default async function Layout(props: {
(innerProps) => {
return <AuthProvider session={session} logoutUrl={env.AUTH_LOGOUT_REDIRECT_URL} {...innerProps} />;
},
(innerProps) => (
<SettingsProvider
user={user}
serverSettings={{
board: {
homeBoardId: serverSettings.board.homeBoardId,
mobileHomeBoardId: serverSettings.board.mobileHomeBoardId,
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
forceDisableStatus: serverSettings.board.forceDisableStatus,
},
search: { defaultSearchEngineId: serverSettings.search.defaultSearchEngineId },
}}
{...innerProps}
/>
),
(innerProps) => <JotaiProvider {...innerProps} />,
(innerProps) => <TRPCReactProvider {...innerProps} />,
(innerProps) => <DayJsLoader {...innerProps} />,
@@ -1,5 +1,5 @@
.bannerContainer {
border-radius: 16px;
border-radius: 8px;
overflow: hidden;
@mixin dark {
background: linear-gradient(
@@ -1,12 +1,12 @@
"use client";
import { useRef } from "react";
import Link from "next/link";
import { Button, Group, Stack, Textarea, TextInput } from "@mantine/core";
import type { z } from "zod";
import { useZodForm } from "@homarr/form";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
import { IconPicker } from "~/components/icons/picker/icon-picker";
@@ -14,49 +14,30 @@ import { IconPicker } from "~/components/icons/picker/icon-picker";
type FormType = z.infer<typeof validation.app.manage>;
interface AppFormProps {
buttonLabels: {
submit: string;
submitAndCreateAnother?: string;
};
submitButtonTranslation: (t: TranslationFunction) => string;
initialValues?: FormType;
handleSubmit: (values: FormType, redirect: boolean, afterSuccess?: () => void) => void;
handleSubmit: (values: FormType) => void;
isPending: boolean;
}
export const AppForm = ({
buttonLabels,
handleSubmit: originalHandleSubmit,
initialValues,
isPending,
}: AppFormProps) => {
export const AppForm = (props: AppFormProps) => {
const { submitButtonTranslation, handleSubmit, initialValues, isPending } = props;
const t = useI18n();
const form = useZodForm(validation.app.manage, {
initialValues: {
name: initialValues?.name ?? "",
description: initialValues?.description ?? "",
iconUrl: initialValues?.iconUrl ?? "",
href: initialValues?.href ?? "",
initialValues: initialValues ?? {
name: "",
description: "",
iconUrl: "",
href: "",
},
});
const shouldCreateAnother = useRef(false);
const handleSubmit = (values: FormType) => {
const redirect = !shouldCreateAnother.current;
const afterSuccess = shouldCreateAnother.current
? () => {
form.reset();
shouldCreateAnother.current = false;
}
: undefined;
originalHandleSubmit(values, redirect, afterSuccess);
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput {...form.getInputProps("name")} withAsterisk label={t("app.field.name.label")} />
<IconPicker {...form.getInputProps("iconUrl")} />
<IconPicker initialValue={initialValues?.iconUrl} {...form.getInputProps("iconUrl")} />
<Textarea {...form.getInputProps("description")} label={t("app.field.description.label")} />
<TextInput {...form.getInputProps("href")} label={t("app.field.url.label")} />
@@ -64,19 +45,8 @@ export const AppForm = ({
<Button variant="default" component={Link} href="/manage/apps">
{t("common.action.backToOverview")}
</Button>
{buttonLabels.submitAndCreateAnother && (
<Button
type="submit"
onClick={() => {
shouldCreateAnother.current = true;
}}
loading={isPending}
>
{buttonLabels.submitAndCreateAnother}
</Button>
)}
<Button type="submit" loading={isPending}>
{buttonLabels.submit}
{submitButtonTranslation(t)}
</Button>
</Group>
</Stack>
@@ -2,14 +2,14 @@
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import type { z } from "zod";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { validation } from "@homarr/validation";
import type { TranslationFunction } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
import type { validation, z } from "@homarr/validation";
import { AppForm } from "../../_form";
@@ -18,15 +18,14 @@ interface AppEditFormProps {
}
export const AppEditForm = ({ app }: AppEditFormProps) => {
const tScoped = useScopedI18n("app.page.edit.notification");
const t = useI18n();
const t = useScopedI18n("app.page.edit.notification");
const router = useRouter();
const { mutate, isPending } = clientApi.app.update.useMutation({
onSuccess: () => {
showSuccessNotification({
title: tScoped("success.title"),
message: tScoped("success.message"),
title: t("success.title"),
message: t("success.message"),
});
void revalidatePathActionAsync("/manage/apps").then(() => {
router.push("/manage/apps");
@@ -34,8 +33,8 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
},
onError: () => {
showErrorNotification({
title: tScoped("error.title"),
message: tScoped("error.message"),
title: t("error.title"),
message: t("error.message"),
});
},
});
@@ -50,11 +49,11 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
[mutate, app.id],
);
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.save"), []);
return (
<AppForm
buttonLabels={{
submit: t("common.action.save"),
}}
submitButtonTranslation={submitButtonTranslation}
initialValues={app}
handleSubmit={handleSubmit}
isPending={isPending}
@@ -2,60 +2,48 @@
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import type { z } from "zod";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { validation } from "@homarr/validation";
import type { TranslationFunction } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
import type { validation, z } from "@homarr/validation";
import { AppForm } from "../_form";
export const AppNewForm = () => {
const tScoped = useScopedI18n("app.page.create.notification");
const t = useI18n();
const t = useScopedI18n("app.page.create.notification");
const router = useRouter();
const { mutate, isPending } = clientApi.app.create.useMutation({
onSuccess: () => {
showSuccessNotification({
title: t("success.title"),
message: t("success.message"),
});
void revalidatePathActionAsync("/manage/apps").then(() => {
router.push("/manage/apps");
});
},
onError: () => {
showErrorNotification({
title: tScoped("error.title"),
message: tScoped("error.message"),
title: t("error.title"),
message: t("error.message"),
});
},
});
const handleSubmit = useCallback(
(values: z.infer<typeof validation.app.manage>, redirect: boolean, afterSuccess?: () => void) => {
mutate(values, {
onSuccess() {
showSuccessNotification({
title: tScoped("success.title"),
message: tScoped("success.message"),
});
afterSuccess?.();
if (!redirect) {
return;
}
void revalidatePathActionAsync("/manage/apps").then(() => {
router.push("/manage/apps");
});
},
});
(values: z.infer<typeof validation.app.manage>) => {
mutate(values);
},
[mutate, router, tScoped],
[mutate],
);
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.create"), []);
return (
<AppForm
buttonLabels={{
submit: t("common.action.create"),
submitAndCreateAnother: t("common.action.createAnother"),
}}
handleSubmit={handleSubmit}
isPending={isPending}
/>
<AppForm submitButtonTranslation={submitButtonTranslation} handleSubmit={handleSubmit} isPending={isPending} />
);
};
@@ -2,7 +2,6 @@ import Link from "next/link";
import { redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
import { IconBox, IconPencil } from "@tabler/icons-react";
import { z } from "zod";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
@@ -10,6 +9,7 @@ import { auth } from "@homarr/auth/next";
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { SearchInput, TablePagination } from "@homarr/ui";
import { z } from "@homarr/validation";
import { ManageContainer } from "~/components/manage/manage-container";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
@@ -45,7 +45,7 @@ export default async function AppsPage(props: AppsPageProps) {
<Stack>
<Title>{t("page.list.title")}</Title>
<Group justify="space-between" align="center">
<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} flexExpand />
<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} />
{session.user.permissions.includes("app-create") && (
<MobileAffixButton component={Link} href="/manage/apps/new">
{t("page.create.title")}
@@ -80,7 +80,7 @@ const AppCard = async ({ app }: AppCardProps) => {
return (
<Card withBorder>
<Group justify="space-between" wrap="nowrap">
<Group align="top" justify="start" wrap="nowrap" style={{ flex: "1" }}>
<Group align="top" justify="start" wrap="nowrap">
<Avatar
size="sm"
src={app.iconUrl}
@@ -91,7 +91,7 @@ const AppCard = async ({ app }: AppCardProps) => {
},
}}
/>
<Stack gap={0} style={{ flex: "1" }}>
<Stack gap={0}>
<Text fw={500} lineClamp={1}>
{app.name}
</Text>
@@ -101,7 +101,7 @@ const AppCard = async ({ app }: AppCardProps) => {
</Text>
)}
{app.href && (
<Anchor href={app.href} lineClamp={1} size="sm" style={{ wordBreak: "break-all" }}>
<Anchor href={app.href} lineClamp={1} size="sm" w="min-content">
{app.href}
</Anchor>
)}
@@ -67,7 +67,7 @@ const BoardCard = async ({ board }: BoardCardProps) => {
const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
return (
<Card radius="lg" withBorder>
<Card withBorder>
<CardSection p="sm" withBorder>
<Group justify="space-between" align="center">
<Group gap="sm">
@@ -106,25 +106,15 @@ const BoardCard = async ({ board }: BoardCardProps) => {
</Group>
</CardSection>
<CardSection>
<Group gap={0} wrap="nowrap">
<Button
style={{ border: "none", borderRadius: 0 }}
component={Link}
href={`/boards/${board.name}`}
variant="default"
fullWidth
>
<CardSection p="sm">
<Group wrap="nowrap">
<Button component={Link} href={`/boards/${board.name}`} variant="default" fullWidth>
{t("action.open.label")}
</Button>
{isMenuVisible && (
<Menu position="bottom-end">
<MenuTarget>
<ActionIcon
style={{ borderTop: "none", borderBottom: "none", borderRight: "none", borderRadius: 0 }}
variant="default"
size="lg"
>
<ActionIcon variant="default" size="lg">
<IconDotsVertical size={16} stroke={1.5} />
</ActionIcon>
</MenuTarget>
@@ -1,4 +1,4 @@
import { IconGrid3x3, IconKey, IconPassword, IconServer, IconUser } from "@tabler/icons-react";
import { IconKey, IconPassword, IconUser } from "@tabler/icons-react";
import type { IntegrationSecretKind } from "@homarr/definitions";
import type { TablerIcon } from "@homarr/ui";
@@ -7,6 +7,4 @@ export const integrationSecretIcons = {
username: IconUser,
apiKey: IconKey,
password: IconPassword,
realm: IconServer,
tokenId: IconGrid3x3,
} satisfies Record<IntegrationSecretKind, TablerIcon>;
@@ -3,7 +3,6 @@
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Button, Fieldset, Group, Stack, TextInput } from "@mantine/core";
import type { z } from "zod";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
@@ -14,6 +13,7 @@ import { convertIntegrationTestConnectionError } from "@homarr/integrations/clie
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
import { SecretCard } from "../../_components/secrets/integration-secret-card";
@@ -70,9 +70,9 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
if (testConnectionError) {
showErrorNotification({
title: t(`integration.testConnection.notification.${testConnectionError.key}.title`),
message:
testConnectionError.message ??
t(`integration.testConnection.notification.${testConnectionError.key}.message`),
message: testConnectionError.message
? testConnectionError.message
: t(`integration.testConnection.notification.${testConnectionError.key}.message`),
});
return;
}
@@ -15,9 +15,7 @@ export const IntegrationCreateDropdownContent = () => {
const [search, setSearch] = useState("");
const filteredKinds = useMemo(() => {
return integrationKinds.filter((kind) =>
getIntegrationName(kind).toLowerCase().includes(search.toLowerCase().trim()),
);
return integrationKinds.filter((kind) => kind.includes(search.toLowerCase()));
}, [search]);
const handleSearch = React.useCallback(
@@ -31,9 +29,7 @@ export const IntegrationCreateDropdownContent = () => {
leftSection={<IconSearch stroke={1.5} size={20} />}
placeholder={t("integration.page.list.search")}
value={search}
data-autofocus
onChange={handleSearch}
variant="filled"
/>
{filteredKinds.length > 0 ? (
@@ -5,7 +5,6 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { Alert, Button, Checkbox, Fieldset, Group, SegmentedControl, Stack, Text, TextInput } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import type { z } from "zod";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
@@ -16,6 +15,7 @@ import { useZodForm } from "@homarr/form";
import { convertIntegrationTestConnectionError } from "@homarr/integrations/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
import { IntegrationSecretInput } from "../_components/secrets/integration-secret-inputs";
@@ -63,9 +63,9 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
if (testConnectionError) {
showErrorNotification({
title: t(`integration.testConnection.notification.${testConnectionError.key}.title`),
message:
testConnectionError.message ??
t(`integration.testConnection.notification.${testConnectionError.key}.message`),
message: testConnectionError.message
? testConnectionError.message
: t(`integration.testConnection.notification.${testConnectionError.key}.message`),
});
return;
}
@@ -1,6 +1,5 @@
import { notFound } from "next/navigation";
import { Container, Group, Stack, Title } from "@mantine/core";
import { z } from "zod";
import { auth } from "@homarr/auth/next";
import type { IntegrationKind } from "@homarr/definitions";
@@ -8,6 +7,7 @@ import { getIntegrationName, integrationKinds } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { IntegrationAvatar } from "@homarr/ui";
import type { validation } from "@homarr/validation";
import { z } from "@homarr/validation";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NewIntegrationForm } from "./_integration-new-form";
@@ -1,36 +0,0 @@
.root {
border-radius: var(--mantine-radius-lg);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
}
.item {
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
border: 1px solid transparent;
position: relative;
z-index: 0;
transition: transform 150ms ease;
overflow: hidden;
&[data-first="true"] {
border-radius: var(--mantine-radius-lg) var(--mantine-radius-lg) 0 0;
}
&[data-last="true"] {
border-radius: 0 0 var(--mantine-radius-lg) var(--mantine-radius-lg);
}
&[data-active] {
transform: scale(1.01);
z-index: 1;
background-color: var(--mantine-color-body);
border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
box-shadow: var(--mantine-shadow-md);
border-radius: var(--mantine-radius-lg);
}
}
.chevron {
&[data-rotate] {
transform: rotate(180deg);
}
}
@@ -44,7 +44,6 @@ import { NoResults } from "~/components/no-results";
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 {
searchParams: Promise<{
@@ -103,15 +102,7 @@ export default async function IntegrationsPage(props: IntegrationsPageProps) {
const IntegrationSelectMenu = ({ children }: PropsWithChildren) => {
return (
<Menu
width={256}
trapFocus
position="bottom-end"
withinPortal
shadow="md"
keepMounted={false}
withInitialFocusPlaceholder={false}
>
<Menu width={256} trapFocus position="bottom-end" withinPortal shadow="md" keepMounted={false}>
{children}
<MenuDropdown>
<IntegrationCreateDropdownContent />
@@ -134,7 +125,7 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
return <NoResults icon={IconPlugX} title={t("page.list.noResults.title")} />;
}
const groupedIntegrations = integrations.reduce(
const grouppedIntegrations = integrations.reduce(
(acc, integration) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!acc[integration.kind]) {
@@ -148,13 +139,11 @@ const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps
{} as Record<IntegrationKind, RouterOutputs["integration"]["all"]>,
);
const entries = objectEntries(groupedIntegrations);
return (
<ActiveTabAccordion defaultValue={activeTab} radius="lg" classNames={classes}>
{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" />}>
<ActiveTabAccordion defaultValue={activeTab} variant="separated">
{objectEntries(grouppedIntegrations).map(([kind, integrations]) => (
<AccordionItem key={kind} value={kind}>
<AccordionControl icon={<IntegrationAvatar size="sm" kind={kind} />}>
<Group>
<Text>{getIntegrationName(kind)}</Text>
<CountBadge count={integrations.length} />
+25 -39
View File
@@ -1,26 +1,25 @@
import type { PropsWithChildren } from "react";
import { AppShellMain } from "@mantine/core";
import {
IconAffiliateFilled,
IconBook2,
IconBox,
IconBrandDiscord,
IconBrandDocker,
IconBrandGithub,
IconBrandTablerFilled,
IconCertificate,
IconClipboardListFilled,
IconDirectionsFilled,
IconGitFork,
IconHelpSquareRoundedFilled,
IconHomeFilled,
IconLayoutDashboardFilled,
IconHome,
IconInfoSmall,
IconLayoutDashboard,
IconLogs,
IconMailForward,
IconPhotoFilled,
IconPointerFilled,
IconPhoto,
IconPlug,
IconQuestionMark,
IconReport,
IconSearch,
IconSettingsFilled,
IconUserFilled,
IconSettings,
IconTool,
IconUser,
IconUsers,
IconUsersGroup,
} from "@tabler/icons-react";
@@ -31,7 +30,6 @@ import { createDocumentationLink } from "@homarr/definitions";
import { getScopedI18n } from "@homarr/translation/server";
import { MainHeader } from "~/components/layout/header";
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";
@@ -42,11 +40,11 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
const navigationLinks: NavigationLink[] = [
{
label: t("items.home"),
icon: IconHomeFilled,
icon: IconHome,
href: "/manage",
},
{
icon: IconLayoutDashboardFilled,
icon: IconLayoutDashboard,
href: "/manage/boards",
label: t("items.boards"),
},
@@ -55,12 +53,9 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
href: "/manage/apps",
label: t("items.apps"),
hidden: !session,
iconProps: {
strokeWidth: 2.5,
},
},
{
icon: IconAffiliateFilled,
icon: IconPlug,
href: "/manage/integrations",
label: t("items.integrations"),
hidden: !session,
@@ -70,18 +65,15 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
href: "/manage/search-engines",
label: t("items.searchEngies"),
hidden: !session,
iconProps: {
strokeWidth: 2.5,
},
},
{
icon: IconPhotoFilled,
icon: IconPhoto,
href: "/manage/medias",
label: t("items.medias"),
hidden: !session,
},
{
icon: IconUserFilled,
icon: IconUser,
label: t("items.users.label"),
hidden: !session?.user.permissions.includes("admin"),
items: [
@@ -105,7 +97,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
},
{
label: t("items.tools.label"),
icon: IconPointerFilled,
icon: IconTool,
// As permissions always include there children permissions, we can check other-view-logs as admin includes it
hidden: !session?.user.permissions.includes("other-view-logs"),
items: [
@@ -117,25 +109,19 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
},
{
label: t("items.tools.items.api"),
icon: IconDirectionsFilled,
icon: IconPlug,
href: "/manage/tools/api",
hidden: !session?.user.permissions.includes("admin"),
},
{
label: t("items.tools.items.logs"),
icon: IconBrandTablerFilled,
icon: IconLogs,
href: "/manage/tools/logs",
hidden: !session?.user.permissions.includes("other-view-logs"),
},
{
label: t("items.tools.items.certificates"),
icon: IconCertificate,
href: "/manage/tools/certificates",
hidden: !session?.user.permissions.includes("admin"),
},
{
label: t("items.tools.items.tasks"),
icon: IconClipboardListFilled,
icon: IconReport,
href: "/manage/tools/tasks",
hidden: !session?.user.permissions.includes("admin"),
},
@@ -144,12 +130,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
{
label: t("items.settings"),
href: "/manage/settings",
icon: IconSettingsFilled,
icon: IconSettings,
hidden: !session?.user.permissions.includes("admin"),
},
{
label: t("items.help.label"),
icon: IconHelpSquareRoundedFilled,
icon: IconQuestionMark,
items: [
{
label: t("items.help.items.documentation"),
@@ -160,7 +146,7 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
{
label: t("items.help.items.submitIssue"),
icon: IconBrandGithub,
href: "https://github.com/homarr-labs/homarr/issues/new/choose",
href: "https://github.com/ajnart/homarr/issues/new/choose",
external: true,
},
{
@@ -172,14 +158,14 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
{
label: t("items.help.items.sourceCode"),
icon: IconGitFork,
href: "https://github.com/homarr-labs/homarr",
href: "https://github.com/ajnart/homarr",
external: true,
},
],
},
{
label: t("items.about"),
icon: homarrLogoPath,
icon: IconInfoSmall,
href: "/manage/about",
},
];
@@ -15,11 +15,6 @@ export const CopyMedia = ({ media }: CopyMediaProps) => {
const url = typeof window !== "undefined" ? `${window.location.origin}/api/user-medias/${media.id}` : "";
// Clipboard only works on localhost or secure connections (https)
if (url.startsWith("http://") && !url.startsWith("http://localhost")) {
return null;
}
return (
<CopyButton value={url}>
{({ copy, copied }) => (
@@ -1,31 +1,16 @@
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
ActionIcon,
Anchor,
Group,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Title,
Tooltip,
} from "@mantine/core";
import { IconExternalLink } from "@tabler/icons-react";
import { z } from "zod";
import { Anchor, Group, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Title } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { humanFileSize } from "@homarr/common";
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { createLocalImageUrl } from "@homarr/icons/local";
import { getI18n } from "@homarr/translation/server";
import { SearchInput, TablePagination, UserAvatar } from "@homarr/ui";
import { z } from "@homarr/validation";
import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
@@ -61,7 +46,7 @@ export default async function GroupsListPage(props: MediaListPageProps) {
const { items: medias, totalCount } = await api.media.getPaginated(searchParams);
return (
<ManageContainer>
<ManageContainer size="xl">
<DynamicBreadcrumb />
<Stack>
<Title>{t("media.plural")}</Title>
@@ -106,14 +91,13 @@ interface RowProps {
const Row = async ({ media }: RowProps) => {
const session = await auth();
const t = await getI18n();
const canDelete = media.creatorId === session?.user.id || session?.user.permissions.includes("media-full-all");
return (
<TableTr>
<TableTd w={64}>
<Image
src={createLocalImageUrl(media.id)}
src={`/api/user-medias/${media.id}`}
alt={media.name}
width={64}
height={64}
@@ -137,17 +121,6 @@ const Row = async ({ media }: RowProps) => {
<TableTd w={64}>
<Group wrap="nowrap" gap="xs">
<CopyMedia media={media} />
<Tooltip label={t("media.action.open.label")} openDelay={500}>
<ActionIcon
component="a"
href={createLocalImageUrl(media.id)}
target="_blank"
color="gray"
variant="subtle"
>
<IconExternalLink size={16} stroke={1.5} />
</ActionIcon>
</Tooltip>
{canDelete && <DeleteMedia media={media} />}
</Group>
</TableTd>
+1 -1
View File
@@ -83,7 +83,7 @@ export default async function ManagementPage() {
{links.map(
(link) =>
!link.hidden && (
<Card component={Link} href={link.href} key={link.href} radius="lg">
<Card component={Link} href={link.href} key={link.href} withBorder>
<Group justify="space-between" wrap="nowrap">
<Group wrap="nowrap">
<Text size="2.4rem" fw="bolder">
@@ -4,13 +4,13 @@ import Link from "next/link";
import type { SegmentedControlItem } from "@mantine/core";
import { Button, Fieldset, Grid, Group, SegmentedControl, Stack, Textarea, TextInput } from "@mantine/core";
import { WidgetIntegrationSelect } from "node_modules/@homarr/widgets/src/widget-integration-select";
import type { z } from "zod";
import { clientApi } from "@homarr/api/client";
import { searchEngineTypes } from "@homarr/definitions";
import { useZodForm } from "@homarr/form";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
import { IconPicker } from "~/components/icons/picker/icon-picker";
@@ -58,7 +58,7 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
/>
</Grid.Col>
</Grid>
<IconPicker {...form.getInputProps("iconUrl")} />
<IconPicker initialValue={initialValues?.iconUrl} {...form.getInputProps("iconUrl")} />
<Fieldset legend={t("search.engine.page.edit.configControl")}>
<SegmentedControl
@@ -2,7 +2,6 @@
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import type { z } from "zod";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
@@ -10,7 +9,7 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { TranslationFunction } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
import type { validation } from "@homarr/validation";
import type { validation, z } from "@homarr/validation";
import { SearchEngineForm } from "../../_form";
@@ -2,14 +2,13 @@
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import type { z } from "zod";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { TranslationFunction } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
import type { validation } from "@homarr/validation";
import type { validation, z } from "@homarr/validation";
import { SearchEngineForm } from "../_form";
@@ -2,7 +2,6 @@ import Link from "next/link";
import { redirect } from "next/navigation";
import { ActionIcon, ActionIconGroup, Anchor, Avatar, Card, Group, Stack, Text, Title } from "@mantine/core";
import { IconPencil, IconSearch } from "@tabler/icons-react";
import { z } from "zod";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
@@ -10,6 +9,7 @@ import { auth } from "@homarr/auth/next";
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { SearchInput, TablePagination } from "@homarr/ui";
import { z } from "@homarr/validation";
import { ManageContainer } from "~/components/manage/manage-container";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
@@ -45,7 +45,7 @@ export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
<Stack>
<Title>{tEngine("page.list.title")}</Title>
<Group justify="space-between" align="center">
<SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} flexExpand />
<SearchInput placeholder={`${tEngine("search")}...`} defaultValue={searchParams.search} />
{session.user.permissions.includes("search-engine-create") && (
<MobileAffixButton component={Link} href="/manage/search-engines/new">
{tEngine("page.create.title")}
@@ -1,6 +1,6 @@
"use client";
import { Group, Switch, Text } from "@mantine/core";
import { Group, Text } from "@mantine/core";
import { IconLayoutDashboard } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
@@ -56,18 +56,6 @@ export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSett
)}
{...form.getInputProps("mobileHomeBoardId")}
/>
<Text fw={500}>{tBoard("status.title")}</Text>
<Switch
{...form.getInputProps("enableStatusByDefault", { type: "checkbox" })}
label={tBoard("status.enableStatusByDefault.label")}
description={tBoard("status.enableStatusByDefault.description")}
/>
<Switch
{...form.getInputProps("forceDisableStatus", { type: "checkbox" })}
label={tBoard("status.forceDisableStatus.label")}
description={tBoard("status.forceDisableStatus.description")}
/>
</>
)}
</CommonSettingsForm>
@@ -1,15 +1,14 @@
"use client";
import { useCallback, useMemo } from "react";
import { ActionIcon, Button, Group, Stack, Text, Title } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import { useMemo } from "react";
import { Button, Group, Stack, Text, Title } from "@mantine/core";
import type { MRT_ColumnDef } from "mantine-react-table";
import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
@@ -21,8 +20,7 @@ interface ApiKeysManagementProps {
export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
const { openModal } = useModalAction(CopyApiKeyModal);
const { openConfirmModal } = useConfirmModal();
const { mutate: mutateCreate, isPending: isPendingCreate } = clientApi.apiKeys.create.useMutation({
const { mutate, isPending } = clientApi.apiKeys.create.useMutation({
async onSuccess(data) {
openModal({
apiKey: data.apiKey,
@@ -30,26 +28,7 @@ export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
await revalidatePathActionAsync("/manage/tools/api");
},
});
const { mutateAsync: mutateDeleteAsync, isPending: isPendingDelete } = clientApi.apiKeys.delete.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/manage/tools/api");
},
});
const t = useScopedI18n("management.page.tool.api.tab.apiKey");
const handleDelete = useCallback(
(id: string) => {
openConfirmModal({
title: t("modal.delete.title"),
children: t("modal.delete.text"),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
await mutateDeleteAsync({ apiKeyId: id });
},
});
},
[t, openConfirmModal, mutateDeleteAsync],
);
const columns = useMemo<MRT_ColumnDef<RouterOutputs["apiKeys"]["getAll"][number]>[]>(
() => [
@@ -67,18 +46,8 @@ export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
</Group>
),
},
{
header: t("table.header.actions"),
Cell: ({ row }) => (
<Group gap="xs">
<ActionIcon onClick={() => handleDelete(row.original.id)} loading={isPendingDelete} c="red">
<IconTrash size="1rem" />
</ActionIcon>
</Group>
),
},
],
[t, handleDelete, isPendingDelete],
[t],
);
const table = useMantineReactTable({
@@ -87,9 +56,9 @@ export const ApiKeysManagement = ({ apiKeys }: ApiKeysManagementProps) => {
renderTopToolbarCustomActions: () => (
<Button
onClick={() => {
mutateCreate();
mutate();
}}
loading={isPendingCreate}
loading={isPending}
>
{t("button.createApiToken")}
</Button>
@@ -1,82 +0,0 @@
"use client";
import { Button, FileInput, Group, Stack } from "@mantine/core";
import { IconCertificate } from "@tabler/icons-react";
import { z } from "zod";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { createModal, useModalAction } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { superRefineCertificateFile } from "@homarr/validation";
export const AddCertificateButton = () => {
const { openModal } = useModalAction(AddCertificateModal);
const t = useI18n();
const handleClick = () => {
openModal({});
};
return <Button onClick={handleClick}>{t("certificate.action.create.label")}</Button>;
};
const AddCertificateModal = createModal(({ actions }) => {
const t = useI18n();
const form = useZodForm(
z.object({
file: z.instanceof(File).nullable().superRefine(superRefineCertificateFile),
}),
{
initialValues: {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
file: null!,
},
},
);
const { mutateAsync } = clientApi.certificates.addCertificate.useMutation();
return (
<form
onSubmit={form.onSubmit(async (values) => {
const formData = new FormData();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
formData.set("file", values.file!);
await mutateAsync(formData, {
async onSuccess() {
showSuccessNotification({
title: t("certificate.action.create.notification.success.title"),
message: t("certificate.action.create.notification.success.message"),
});
await revalidatePathActionAsync("/manage/tools/certificates");
actions.closeModal();
},
onError() {
showErrorNotification({
title: t("certificate.action.create.notification.error.title"),
message: t("certificate.action.create.notification.error.message"),
});
},
});
})}
>
<Stack>
<FileInput leftSection={<IconCertificate size={16} />} {...form.getInputProps("file")} />
<Group justify="end">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button type="submit" loading={form.submitting}>
{t("common.action.add")}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle(t) {
return t("certificate.action.create.label");
},
});
@@ -1,54 +0,0 @@
"use client";
import { ActionIcon } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
interface RemoveCertificateProps {
fileName: string;
}
export const RemoveCertificate = ({ fileName }: RemoveCertificateProps) => {
const { openConfirmModal } = useConfirmModal();
const { mutateAsync } = clientApi.certificates.removeCertificate.useMutation();
const t = useI18n();
const handleClick = () => {
openConfirmModal({
title: t("certificate.action.remove.label"),
children: t("certificate.action.remove.confirm"),
// eslint-disable-next-line no-restricted-syntax
async onConfirm() {
await mutateAsync(
{ fileName },
{
async onSuccess() {
showSuccessNotification({
title: t("certificate.action.remove.notification.success.title"),
message: t("certificate.action.remove.notification.success.message"),
});
await revalidatePathActionAsync("/manage/tools/certificates");
},
onError() {
showErrorNotification({
title: t("certificate.action.remove.notification.error.title"),
message: t("certificate.action.remove.notification.error.message"),
});
},
},
);
},
});
};
return (
<ActionIcon onClick={handleClick} color="red" variant="subtle">
<IconTrash color="red" size={16} />
</ActionIcon>
);
};
@@ -1,104 +0,0 @@
import { X509Certificate } from "node:crypto";
import { notFound } from "next/navigation";
import { Card, Group, SimpleGrid, Stack, Text, Title } from "@mantine/core";
import { IconCertificate, IconCertificateOff } from "@tabler/icons-react";
import dayjs from "dayjs";
import { auth } from "@homarr/auth/next";
import { loadCustomRootCertificatesAsync } from "@homarr/certificates/server";
import { getMantineColor } from "@homarr/common";
import type { SupportedLanguage } from "@homarr/translation";
import { getI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results";
import { AddCertificateButton } from "./_components/add-certificate";
import { RemoveCertificate } from "./_components/remove-certificate";
interface CertificatesPageProps {
params: Promise<{
locale: SupportedLanguage;
}>;
}
export default async function CertificatesPage({ params }: CertificatesPageProps) {
const session = await auth();
if (!session?.user.permissions.includes("admin")) {
notFound();
}
const { locale } = await params;
const t = await getI18n();
const certificates = await loadCustomRootCertificatesAsync();
const x509Certificates = certificates
.map((cert) => ({
...cert,
x509: new X509Certificate(cert.content),
}))
.sort((certA, certB) => certA.x509.validToDate.getTime() - certB.x509.validToDate.getTime());
return (
<>
<DynamicBreadcrumb />
<Stack>
<Group justify="space-between">
<Stack gap={4}>
<Title>{t("certificate.page.list.title")}</Title>
<Text>{t("certificate.page.list.description")}</Text>
</Stack>
<AddCertificateButton />
</Group>
{x509Certificates.length === 0 && (
<NoResults icon={IconCertificateOff} title={t("certificate.page.list.noResults.title")} />
)}
<SimpleGrid cols={{ sm: 1, lg: 2, xl: 3 }} spacing="lg">
{x509Certificates.map((cert) => (
<Card key={cert.x509.fingerprint} withBorder>
<Group wrap="nowrap">
<IconCertificate
color={getMantineColor(iconColor(cert.x509.validToDate), 6)}
style={{ minWidth: 32 }}
size={32}
stroke={1.5}
/>
<Stack flex={1} gap="xs" maw="calc(100% - 48px)">
<Group justify="space-between" wrap="nowrap">
<Text fw={500} lineClamp={1} style={{ wordBreak: "break-all" }}>
{cert.x509.subject}
</Text>
<Text c="gray.6" ta="end" size="sm">
{cert.fileName}
</Text>
</Group>
<Group justify="space-between">
<Text size="sm" c="gray.6" title={cert.x509.validToDate.toISOString()}>
{t("certificate.page.list.expires", {
when: new Intl.RelativeTimeFormat(locale).format(
dayjs(cert.x509.validToDate).diff(dayjs(), "days"),
"days",
),
})}
</Text>
<RemoveCertificate fileName={cert.fileName} />
</Group>
</Stack>
</Group>
</Card>
))}
</SimpleGrid>
</Stack>
</>
);
}
const iconColor = (validTo: Date) => {
const daysUntilInvalid = dayjs(validTo).diff(new Date(), "days");
if (daysUntilInvalid < 1) return "red";
if (daysUntilInvalid < 7) return "orange";
if (daysUntilInvalid < 30) return "yellow";
return "green";
};
@@ -16,7 +16,7 @@ import { MantineReactTable } from "mantine-react-table";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useTimeAgo } from "@homarr/common";
import type { ContainerState } from "@homarr/docker";
import type { DockerContainerState } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -246,9 +246,9 @@ const containerStates = {
exited: "red",
removing: "pink",
dead: "dark",
} satisfies Record<ContainerState, MantineColor>;
} satisfies Record<DockerContainerState, MantineColor>;
const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
const ContainerStateBadge = ({ state }: { state: DockerContainerState }) => {
const t = useScopedI18n("docker.field.state.option");
return (
@@ -1,7 +1,6 @@
"use client";
import { Button, Group, Select, Stack, Switch } from "@mantine/core";
import type { z } from "zod";
import { Button, Group, Select, Stack } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
@@ -9,38 +8,37 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
interface ChangeSearchPreferencesFormProps {
interface ChangeDefaultSearchEngineFormProps {
user: RouterOutputs["user"]["getById"];
searchEnginesData: { value: string; label: string }[];
}
export const ChangeSearchPreferencesForm = ({ user, searchEnginesData }: ChangeSearchPreferencesFormProps) => {
export const ChangeDefaultSearchEngineForm = ({ user, searchEnginesData }: ChangeDefaultSearchEngineFormProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.user.changeSearchPreferences.useMutation({
const { mutate, isPending } = clientApi.user.changeDefaultSearchEngine.useMutation({
async onSettled() {
await revalidatePathActionAsync(`/manage/users/${user.id}`);
},
onSuccess(_, variables) {
form.setInitialValues({
defaultSearchEngineId: variables.defaultSearchEngineId,
openInNewTab: variables.openInNewTab,
});
showSuccessNotification({
message: t("user.action.changeSearchPreferences.notification.success.message"),
message: t("user.action.changeDefaultSearchEngine.notification.success.message"),
});
},
onError() {
showErrorNotification({
message: t("user.action.changeSearchPreferences.notification.error.message"),
message: t("user.action.changeDefaultSearchEngine.notification.error.message"),
});
},
});
const form = useZodForm(validation.user.changeSearchPreferences, {
const form = useZodForm(validation.user.changeDefaultSearchEngine, {
initialValues: {
defaultSearchEngineId: user.defaultSearchEngineId,
openInNewTab: user.openSearchInNewTab,
defaultSearchEngineId: user.defaultSearchEngineId ?? "",
},
});
@@ -54,16 +52,7 @@ export const ChangeSearchPreferencesForm = ({ user, searchEnginesData }: ChangeS
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Select
label={t("user.field.defaultSearchEngine.label")}
w="100%"
data={searchEnginesData}
{...form.getInputProps("defaultSearchEngineId")}
/>
<Switch
label={t("user.field.openSearchInNewTab.label")}
{...form.getInputProps("openInNewTab", { type: "checkbox" })}
/>
<Select w="100%" data={searchEnginesData} {...form.getInputProps("defaultSearchEngineId")} />
<Group justify="end">
<Button type="submit" color="teal" loading={isPending}>
@@ -75,4 +64,4 @@ export const ChangeSearchPreferencesForm = ({ user, searchEnginesData }: ChangeS
);
};
type FormType = z.infer<typeof validation.user.changeSearchPreferences>;
type FormType = z.infer<typeof validation.user.changeDefaultSearchEngine>;
@@ -1,7 +1,6 @@
"use client";
import { Button, Group, Select, Stack } from "@mantine/core";
import type { z } from "zod";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
@@ -9,6 +8,7 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
interface ChangeHomeBoardFormProps {
@@ -39,8 +39,8 @@ export const ChangeHomeBoardForm = ({ user, boardsData }: ChangeHomeBoardFormPro
});
const form = useZodForm(validation.user.changeHomeBoards, {
initialValues: {
homeBoardId: user.homeBoardId,
mobileHomeBoardId: user.mobileHomeBoardId,
homeBoardId: user.homeBoardId ?? "",
mobileHomeBoardId: user.mobileHomeBoardId ?? "",
},
});
@@ -4,7 +4,6 @@ import { Button, Group, Radio, Stack } from "@mantine/core";
import type { DayOfWeek } from "@mantine/dates";
import dayjs from "dayjs";
import localeData from "dayjs/plugin/localeData";
import type { z } from "zod";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
@@ -12,6 +11,7 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
dayjs.extend(localeData);
@@ -1,7 +1,6 @@
"use client";
import { Button, Group, Stack, Switch } from "@mantine/core";
import type { z } from "zod";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
@@ -9,6 +8,7 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
interface PingIconsEnabledProps {
@@ -51,10 +51,7 @@ export const PingIconsEnabled = ({ user }: PingIconsEnabledProps) => {
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Switch
{...form.getInputProps("pingIconsEnabled", { type: "checkbox" })}
label={t("user.field.pingIconsEnabled.label")}
/>
<Switch {...form.getInputProps("pingIconsEnabled")} label={t("user.field.pingIconsEnabled.label")} />
<Group justify="end">
<Button type="submit" color="teal" loading={isPending}>
@@ -11,8 +11,8 @@ import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone"
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
import { createMetaTitle } from "~/metadata";
import { canAccessUserEditPage } from "../access";
import { ChangeDefaultSearchEngineForm } from "./_components/_change-default-search-engine";
import { ChangeHomeBoardForm } from "./_components/_change-home-board";
import { ChangeSearchPreferencesForm } from "./_components/_change-search-preferences";
import { DeleteUserButton } from "./_components/_delete-user-button";
import { FirstDayOfWeek } from "./_components/_first-day-of-week";
import { PingIconsEnabled } from "./_components/_ping-icons-enabled";
@@ -83,12 +83,10 @@ export default async function EditUserPage(props: Props) {
</Box>
</Group>
{session?.user.id === user.id && (
<Stack mb="lg">
<Title order={2}>{tGeneral("item.language")}</Title>
<CurrentLanguageCombobox />
</Stack>
)}
<Stack mb="lg">
<Title order={2}>{tGeneral("item.language")}</Title>
<CurrentLanguageCombobox />
</Stack>
<Stack mb="lg">
<Title order={2}>{tGeneral("item.board.title")}</Title>
@@ -102,8 +100,8 @@ export default async function EditUserPage(props: Props) {
</Stack>
<Stack mb="lg">
<Title order={2}>{tGeneral("item.search")}</Title>
<ChangeSearchPreferencesForm user={user} searchEnginesData={searchEngines} />
<Title order={2}>{tGeneral("item.defaultSearchEngine")}</Title>
<ChangeDefaultSearchEngineForm user={user} searchEnginesData={searchEngines} />
</Stack>
<Stack mb="lg">
@@ -17,7 +17,6 @@ import {
} from "@mantine/core";
import { useListState } from "@mantine/hooks";
import { IconPlus, IconUserCheck } from "@tabler/icons-react";
import { z } from "zod";
import { clientApi } from "@homarr/api/client";
import { everyoneGroup, groupPermissions } from "@homarr/definitions";
@@ -27,7 +26,7 @@ import { useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { CustomPasswordInput, UserAvatar } from "@homarr/ui";
import { validation } from "@homarr/validation";
import { validation, z } from "@homarr/validation";
import { createCustomErrorParams } from "@homarr/validation/form";
import { GroupSelectModal } from "~/components/access/group-select-modal";
@@ -1,7 +1,6 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { Anchor, Group, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Title } from "@mantine/core";
import { z } from "zod";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
@@ -9,6 +8,7 @@ import { auth } from "@homarr/auth/next";
import type { inferSearchParamsFromSchema } from "@homarr/common/types";
import { getI18n } from "@homarr/translation/server";
import { SearchInput, TablePagination, UserAvatarGroup } from "@homarr/ui";
import { z } from "@homarr/validation";
import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
@@ -9,7 +9,6 @@ import { ErrorBoundary } from "react-error-boundary";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import { showSuccessNotification } from "@homarr/notifications";
import { useSettings } from "@homarr/settings";
import { useScopedI18n } from "@homarr/translation/client";
import type { BoardItemAdvancedOptions } from "@homarr/validation";
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
@@ -30,7 +29,6 @@ interface WidgetPreviewPageContentProps {
}
export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPreviewPageContentProps) => {
const settings = useSettings();
const t = useScopedI18n("widgetPreview");
const { openModal: openWidgetEditModal } = useModalAction(WidgetEditModal);
const { openModal: openPreviewDimensionsModal } = useModalAction(PreviewDimensionsModal);
@@ -45,7 +43,7 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
integrationIds: string[];
advancedOptions: BoardItemAdvancedOptions;
}>({
options: reduceWidgetOptionsWithDefaultValues(kind, settings, {}),
options: reduceWidgetOptionsWithDefaultValues(kind, {}),
integrationIds: [],
advancedOptions: {
customCssClasses: [],
@@ -65,9 +63,8 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
),
integrationSupport: "supportedIntegrations" in currentDefinition,
settings,
});
}, [currentDefinition, integrationData, kind, openWidgetEditModal, settings, state]);
}, [currentDefinition, integrationData, kind, openWidgetEditModal, state]);
const Comp = loadWidgetDynamic(kind);
+2 -5
View File
@@ -1,7 +1,6 @@
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter, createTRPCContext } from "@homarr/api";
import { trpcPath } from "@homarr/api/shared";
import { auth } from "@homarr/auth/next";
import { logger } from "@homarr/log";
@@ -26,14 +25,12 @@ export function OPTIONS() {
const handler = auth(async (req) => {
const response = await fetchRequestHandler({
endpoint: trpcPath,
endpoint: "/api/trpc",
router: appRouter,
req,
createContext: () => createTRPCContext({ session: req.auth, headers: req.headers }),
onError({ error, path, type }) {
logger.error(
`tRPC Error with ${type} on '${path}': (${error.code}) - ${error.message}\n${error.stack}\n${error.cause}`,
);
logger.error(`tRPC Error with ${type} on '${path}': (${error.code}) - ${error.message}`);
},
});
@@ -1,62 +0,0 @@
import type { Modify } from "@homarr/common/types";
import { createId } from "@homarr/db/client";
import type { WidgetKind } from "@homarr/definitions";
import type { Board, DynamicSection, EmptySection, Item } from "~/app/[locale]/boards/_types";
import { getFirstEmptyPosition } from "./empty-position";
export interface CreateItemInput {
kind: WidgetKind;
}
export const createItemCallback =
({ kind }: CreateItemInput) =>
(previous: Board): Board => {
const firstSection = previous.sections
.filter((section): section is EmptySection => section.kind === "empty")
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
.at(0);
if (!firstSection) return previous;
const dynamicSectionsOfFirstSection = previous.sections.filter(
(section): section is DynamicSection => section.kind === "dynamic" && section.parentSectionId === firstSection.id,
);
const elements = [...firstSection.items, ...dynamicSectionsOfFirstSection];
const emptyPosition = getFirstEmptyPosition(elements, previous.columnCount);
if (!emptyPosition) {
console.error("Your board is full");
return previous;
}
const widget = {
id: createId(),
kind,
options: {},
width: 1,
height: 1,
...emptyPosition,
integrationIds: [],
advancedOptions: {
customCssClasses: [],
},
} satisfies Modify<
Item,
{
kind: WidgetKind;
}
>;
return {
...previous,
sections: previous.sections.map((section) => {
// Return same section if item is not in it
if (section.id !== firstSection.id) return section;
return {
...section,
items: section.items.concat(widget),
};
}),
};
};
@@ -1,81 +0,0 @@
import { createId } from "@homarr/db/client";
import type { Board, DynamicSection, EmptySection } from "~/app/[locale]/boards/_types";
import { getFirstEmptyPosition } from "./empty-position";
export interface DuplicateItemInput {
itemId: string;
}
export const duplicateItemCallback =
({ itemId }: DuplicateItemInput) =>
(previous: Board): Board => {
const itemToDuplicate = previous.sections
.flatMap((section) => section.items.map((item) => ({ ...item, sectionId: section.id })))
.find((item) => item.id === itemId);
if (!itemToDuplicate) return previous;
const currentSection = previous.sections.find((section) => section.id === itemToDuplicate.sectionId);
if (!currentSection) return previous;
const dynamicSectionsOfCurrentSection = previous.sections.filter(
(section): section is DynamicSection =>
section.kind === "dynamic" && section.parentSectionId === currentSection.id,
);
const elements = [...currentSection.items, ...dynamicSectionsOfCurrentSection];
let sectionId = currentSection.id;
let emptyPosition = getFirstEmptyPosition(
elements,
currentSection.kind === "dynamic" ? currentSection.width : previous.columnCount,
currentSection.kind === "dynamic" ? currentSection.height : undefined,
{
width: itemToDuplicate.width,
height: itemToDuplicate.height,
},
);
if (!emptyPosition) {
const firstSection = previous.sections
.filter((section): section is EmptySection => section.kind === "empty")
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
.at(0);
if (!firstSection) return previous;
const dynamicSectionsOfFirstSection = previous.sections.filter(
(section): section is DynamicSection =>
section.kind === "dynamic" && section.parentSectionId === firstSection.id,
);
const elements = [...firstSection.items, ...dynamicSectionsOfFirstSection];
emptyPosition = getFirstEmptyPosition(elements, previous.columnCount, undefined, {
width: itemToDuplicate.width,
height: itemToDuplicate.height,
});
if (!emptyPosition) {
console.error("Your board is full");
return previous;
}
sectionId = firstSection.id;
}
const widget = structuredClone(itemToDuplicate);
widget.id = createId();
widget.xOffset = emptyPosition.xOffset;
widget.yOffset = emptyPosition.yOffset;
widget.sectionId = sectionId;
const result = {
...previous,
sections: previous.sections.map((section) => {
// Return same section if item is not in it
if (section.id !== sectionId) return section;
return {
...section,
items: section.items.concat(widget),
};
}),
};
return result;
};
@@ -1,25 +0,0 @@
import type { Item } from "~/app/[locale]/boards/_types";
export const getFirstEmptyPosition = (
elements: Pick<Item, "yOffset" | "xOffset" | "width" | "height">[],
columnCount: number,
rowCount = 9999,
size: { width: number; height: number } = { width: 1, height: 1 },
) => {
for (let yOffset = 0; yOffset < rowCount + 1 - size.height; yOffset++) {
for (let xOffset = 0; xOffset < columnCount; xOffset++) {
const isOccupied = elements.some(
(element) =>
element.yOffset < yOffset + size.height &&
element.yOffset + element.height > yOffset &&
element.xOffset < xOffset + size.width &&
element.xOffset + element.width > xOffset,
);
if (!isOccupied) {
return { xOffset, yOffset };
}
}
}
return undefined;
};
@@ -1,61 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import type { Board } from "~/app/[locale]/boards/_types";
import { createItemCallback } from "../create-item";
import * as emptyPosition from "../empty-position";
import { createDynamicSection, createEmptySection, createItem } from "./shared";
describe("item actions create-item", () => {
test("should add it to first section", () => {
const spy = vi.spyOn(emptyPosition, "getFirstEmptyPosition");
spy.mockReturnValue({ xOffset: 5, yOffset: 5 });
const input = {
sections: [createEmptySection("1", 2), createEmptySection("2", 0), createEmptySection("3", 1)],
columnCount: 4,
} satisfies Pick<Board, "sections" | "columnCount">;
const result = createItemCallback({
kind: "clock",
})(input as unknown as Board);
const firstSection = result.sections.find((section) => section.id === "2");
const item = firstSection?.items.at(0);
expect(item).toEqual(expect.objectContaining({ kind: "clock", xOffset: 5, yOffset: 5 }));
expect(spy).toHaveBeenCalledWith([], input.columnCount);
});
test("should correctly pass dynamic section and items to getFirstEmptyPosition", () => {
const spy = vi.spyOn(emptyPosition, "getFirstEmptyPosition");
spy.mockReturnValue({ xOffset: 5, yOffset: 5 });
const firstSection = createEmptySection("2", 0);
const expectedItem = createItem({ id: "12", xOffset: 1, yOffset: 2, width: 3, height: 2 });
firstSection.items.push(expectedItem);
const dynamicSectionInFirst = createDynamicSection({
id: "4",
parentSectionId: "2",
yOffset: 0,
xOffset: 0,
width: 2,
height: 2,
});
const input = {
sections: [
createEmptySection("1", 2),
firstSection,
createEmptySection("3", 1),
dynamicSectionInFirst,
createDynamicSection({ id: "5", parentSectionId: "3", yOffset: 1 }),
],
columnCount: 4,
} satisfies Pick<Board, "sections" | "columnCount">;
const result = createItemCallback({
kind: "clock",
})(input as unknown as Board);
const firstSectionResult = result.sections.find((section) => section.id === "2");
const item = firstSectionResult?.items.find((item) => item.id !== "12");
expect(item).toEqual(expect.objectContaining({ kind: "clock", xOffset: 5, yOffset: 5 }));
expect(spy).toHaveBeenCalledWith([expectedItem, dynamicSectionInFirst], input.columnCount);
});
});
@@ -1,52 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import type { Board } from "~/app/[locale]/boards/_types";
import { duplicateItemCallback } from "../duplicate-item";
import * as emptyPosition from "../empty-position";
import { createEmptySection, createItem } from "./shared";
describe("item actions duplicate-item", () => {
test("should copy it in the same section", () => {
const spy = vi.spyOn(emptyPosition, "getFirstEmptyPosition");
spy.mockReturnValue({ xOffset: 5, yOffset: 5 });
const currentSection = createEmptySection("2", 1);
const currentItem = createItem({
id: "1",
xOffset: 1,
yOffset: 3,
width: 3,
height: 2,
kind: "minecraftServerStatus",
integrationIds: ["1"],
options: { address: "localhost" },
advancedOptions: { customCssClasses: ["test"] },
});
const otherItem = createItem({
id: "2",
});
currentSection.items.push(currentItem, otherItem);
const input = {
columnCount: 10,
sections: [createEmptySection("1", 0), currentSection, createEmptySection("3", 2)],
} satisfies Pick<Board, "sections" | "columnCount">;
const result = duplicateItemCallback({ itemId: currentItem.id })(input as unknown as Board);
const section = result.sections.find((section) => section.id === "2");
expect(section?.items.length).toBe(3);
const duplicatedItem = section?.items.find((item) => item.id !== currentItem.id && item.id !== otherItem.id);
expect(duplicatedItem).toEqual(
expect.objectContaining({
kind: "minecraftServerStatus",
xOffset: 5,
yOffset: 5,
width: 3,
height: 2,
integrationIds: ["1"],
options: { address: "localhost" },
advancedOptions: { customCssClasses: ["test"] },
}),
);
});
});
@@ -1,138 +0,0 @@
import { describe, expect, test } from "vitest";
import type { Item } from "~/app/[locale]/boards/_types";
import { getFirstEmptyPosition } from "../empty-position";
describe("get first empty position", () => {
test.each([
[[[" ", " ", " ", " "]], [1, 1], 0, 0],
[[["a", " ", " ", " "]], [1, 1], 1, 0],
[[[" ", "a", " ", " "]], [1, 1], 0, 0],
[
[
["a", "a", " ", " "],
["a", "a", " ", " "],
],
[1, 1],
2,
0,
],
[[["a", "a", "a", "a"]], [1, 1], 0, 1],
[
[
["a", "a", "a", "a"],
["a", "a", "a", "a"],
],
[1, 1],
0,
2,
],
[
[
["a", "a", " ", "b", "b"],
["a", "a", " ", "b", "b"],
],
[1, 2],
2,
0,
],
[
[
["a", "a", " ", " ", "b", "b"],
["a", "a", " ", " ", "b", "b"],
],
[2, 2],
2,
0,
],
[
[
["a", "a", " ", "d", "b", "b"],
["a", "a", "c", "e", "b", "b"],
],
[1, 1],
2,
0,
],
[
[
["a", "a", " ", " ", "b", "b"],
["a", "a", " ", "e", "b", "b"],
],
[2, 2],
0,
2,
],
])("should return the first empty position", (layout, size, expectedX, expectedY) => {
const elements = createElementsFromLayout(layout);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const result = getFirstEmptyPosition(elements, layout[0]!.length, undefined, { width: size[0]!, height: size[1]! });
expect(result).toEqual({ xOffset: expectedX, yOffset: expectedY });
});
test.each([
[[[" ", " "]], [1, 1], 0, 0, 1],
[[["a", " "]], [1, 1], 1, 0, 1],
[[["a", "a"]], [1, 1], undefined, undefined, 1],
[[["a", "a"]], [1, 1], 0, 1, 2],
[
[
["a", "b", " ", " "],
["a", "c", " ", "d"],
],
[2, 2],
undefined,
undefined,
3,
],
[
[
["a", "b", " ", " "],
["a", "c", " ", "d"],
],
[2, 2],
0,
2,
4,
],
[[["a", "b"]], [2, 1], 0, 1, 2],
])("should return the first empty position with limited rows", (layout, size, expectedX, expectedY, rowCount) => {
const elements = createElementsFromLayout(layout);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const result = getFirstEmptyPosition(elements, layout[0]!.length, rowCount, { width: size[0]!, height: size[1]! });
expect(result).toEqual(expectedX !== undefined ? { xOffset: expectedX, yOffset: expectedY } : undefined);
});
});
const createElementsFromLayout = (layout: string[][]) => {
const elements: (Pick<Item, "xOffset" | "yOffset" | "width" | "height"> & { char: string })[] = [];
for (let yOffset = 0; yOffset < layout.length; yOffset++) {
const row = layout[yOffset];
if (!row) continue;
for (let xOffset = 0; xOffset < row.length; xOffset++) {
const item = row[xOffset];
if (item === " " || !item) continue;
const existing = elements.find((element) => element.char === item);
if (existing) {
existing.height = yOffset - existing.yOffset + 1;
existing.width = xOffset - existing.xOffset + 1;
continue;
}
elements.push({
yOffset,
xOffset,
width: 1,
height: 1,
char: item,
});
}
}
return elements;
};
@@ -1,32 +0,0 @@
import type { DynamicSection, EmptySection, Item } from "~/app/[locale]/boards/_types";
export const createEmptySection = (id: string, yOffset: number): EmptySection => ({
id,
kind: "empty",
yOffset,
xOffset: 0,
items: [],
});
export const createDynamicSection = (section: Omit<Partial<DynamicSection>, "kind">): DynamicSection => ({
id: section.id ?? "0",
kind: "dynamic",
parentSectionId: section.parentSectionId ?? "0",
height: section.height ?? 1,
width: section.width ?? 1,
yOffset: section.yOffset ?? 0,
xOffset: section.xOffset ?? 0,
items: section.items ?? [],
});
export const createItem = (item: Partial<Item>): Item => ({
id: item.id ?? "0",
width: item.width ?? 1,
height: item.height ?? 1,
yOffset: item.yOffset ?? 0,
xOffset: item.xOffset ?? 0,
kind: item.kind ?? "clock",
integrationIds: item.integrationIds ?? [],
options: item.options ?? {},
advancedOptions: item.advancedOptions ?? { customCssClasses: [] },
});
@@ -1,13 +1,12 @@
import { useCallback } from "react";
import { useUpdateBoard } from "@homarr/boards/updater";
import type { Modify } from "@homarr/common/types";
import { createId } from "@homarr/db/client";
import type { WidgetKind } from "@homarr/definitions";
import type { BoardItemAdvancedOptions } from "@homarr/validation";
import type { Item } from "~/app/[locale]/boards/_types";
import type { CreateItemInput } from "./actions/create-item";
import { createItemCallback } from "./actions/create-item";
import type { DuplicateItemInput } from "./actions/duplicate-item";
import { duplicateItemCallback } from "./actions/duplicate-item";
import type { EmptySection, Item } from "~/app/[locale]/boards/_types";
import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client";
interface MoveAndResizeItem {
itemId: string;
@@ -43,19 +42,87 @@ interface UpdateItemIntegrations {
newIntegrations: string[];
}
interface CreateItem {
kind: WidgetKind;
}
interface DuplicateItem {
itemId: string;
}
export const useItemActions = () => {
const { updateBoard } = useUpdateBoard();
const createItem = useCallback(
(input: CreateItemInput) => {
updateBoard(createItemCallback(input));
({ kind }: CreateItem) => {
updateBoard((previous) => {
const lastSection = previous.sections
.filter((section): section is EmptySection => section.kind === "empty")
.sort((sectionA, sectionB) => sectionB.yOffset - sectionA.yOffset)[0];
if (!lastSection) return previous;
const widget = {
id: createId(),
kind,
options: {},
width: 1,
height: 1,
integrationIds: [],
advancedOptions: {
customCssClasses: [],
},
} satisfies Modify<
Omit<Item, "yOffset" | "xOffset">,
{
kind: WidgetKind;
}
>;
return {
...previous,
sections: previous.sections.map((section) => {
// Return same section if item is not in it
if (section.id !== lastSection.id) return section;
return {
...section,
items: section.items.concat(widget as unknown as Item),
};
}),
};
});
},
[updateBoard],
);
const duplicateItem = useCallback(
({ itemId }: DuplicateItemInput) => {
updateBoard(duplicateItemCallback({ itemId }));
({ itemId }: DuplicateItem) => {
updateBoard((previous) => {
const itemToDuplicate = previous.sections
.flatMap((section) => section.items)
.find((item) => item.id === itemId);
if (!itemToDuplicate) return previous;
const newItem = {
...itemToDuplicate,
id: createId(),
yOffset: undefined,
xOffset: undefined,
} satisfies Modify<Item, { yOffset?: number; xOffset?: number }>;
return {
...previous,
sections: previous.sections.map((section) => {
// Return same section if item is not in it
if (!section.items.some((item) => item.id === itemId)) return section;
return {
...section,
items: section.items.concat(newItem as unknown as Item),
};
}),
};
});
},
[updateBoard],
);
@@ -5,13 +5,11 @@ import combineClasses from "clsx";
import { NoIntegrationSelectedError } from "node_modules/@homarr/widgets/src/errors";
import { ErrorBoundary } from "react-error-boundary";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useSettings } from "@homarr/settings";
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import { WidgetError } from "@homarr/widgets/errors";
import type { Item } from "~/app/[locale]/boards/_types";
import { useEditMode, useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
import classes from "../sections/item.module.css";
import { useItemActions } from "./item-actions";
import { BoardItemMenu } from "./item-menu";
@@ -55,12 +53,11 @@ interface InnerContentProps {
}
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
const settings = useSettings();
const board = useRequiredBoard();
const [isEditMode] = useEditMode();
const Comp = loadWidgetDynamic(item.kind);
const { definition } = widgetImports[item.kind];
const options = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
const options = reduceWidgetOptionsWithDefaultValues(item.kind, item.options);
const newItem = { ...item, options };
const { updateItemOptions } = useItemActions();
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
@@ -82,12 +79,7 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
>
<Throw
error={new NoIntegrationSelectedError()}
when={
widgetSupportsIntegrations &&
item.integrationIds.length === 0 &&
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false)
}
when={widgetSupportsIntegrations && item.integrationIds.length === 0}
/>
<BoardItemMenu offset={4} item={newItem} />
<Comp
@@ -3,14 +3,13 @@ import { ActionIcon, Menu } from "@mantine/core";
import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { widgetImports } from "@homarr/widgets";
import { WidgetEditModal } from "@homarr/widgets/modals";
import type { Item } from "~/app/[locale]/boards/_types";
import { useEditMode } from "~/app/[locale]/boards/(content)/_context";
import { useSectionContext } from "../sections/section-context";
import { useItemActions } from "./item-actions";
import { ItemMoveModal } from "./item-move-modal";
@@ -36,7 +35,6 @@ export const BoardItemMenu = ({
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
const { gridstack } = useSectionContext().refs;
const settings = useSettings();
// Reset error boundary on next render if item has been edited
useEffect(() => {
@@ -77,7 +75,6 @@ export const BoardItemMenu = ({
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
),
integrationSupport: "supportedIntegrations" in currentDefinition,
settings,
});
};
@@ -1,11 +1,11 @@
import { useCallback, useRef } from "react";
import { Button, Grid, Group, NumberInput, Stack } from "@mantine/core";
import { z } from "zod";
import { useZodForm } from "@homarr/form";
import type { GridStack } from "@homarr/gridstack";
import { createModal } from "@homarr/modals";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { z } from "@homarr/validation";
import type { Item } from "~/app/[locale]/boards/_types";
@@ -1,12 +1,12 @@
"use client";
import { Button, Group, Stack, TextInput } from "@mantine/core";
import type { z } from "zod";
import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
interface InnerProps {
@@ -2,32 +2,19 @@ import { Card, Collapse, Group, Stack, Title, UnstyledButton } from "@mantine/co
import { useDisclosure } from "@mantine/hooks";
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/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";
interface Props {
section: CategorySection;
}
export const BoardCategorySection = ({ section }: Props) => {
const { mutate } = clientApi.section.changeCollapsed.useMutation();
const board = useRequiredBoard();
const [opened, { toggle }] = useDisclosure(section.collapsed, {
onOpen() {
mutate({ sectionId: section.id, collapsed: true });
},
onClose() {
mutate({ sectionId: section.id, collapsed: false });
},
});
const [opened, { toggle }] = useDisclosure(false);
return (
<Card style={{ "--opacity": board.opacity / 100 }} withBorder p={0} className={classes.itemCard}>
<Card withBorder p={0}>
<Stack>
<Group wrap="nowrap" gap="sm">
<UnstyledButton w="100%" p="sm" onClick={toggle}>
@@ -67,7 +67,6 @@ const createSections = (categoryCount: number) => {
name: `Category ${index}`,
yOffset: index,
xOffset: 0,
collapsed: false,
items: [],
})) satisfies Section[];
};
@@ -118,7 +118,6 @@ const createSections = (initialYOffsets: number[]) => {
id: yOffset.toString(),
kind: index % 2 === 0 ? "empty" : "category",
name: "Category",
collapsed: false,
yOffset,
xOffset: 0,
items: [createItem({ id: yOffset.toString() })],
@@ -1,9 +1,9 @@
import { useCallback } from "react";
import { useUpdateBoard } from "@homarr/boards/updater";
import { createId } from "@homarr/db/client";
import type { CategorySection, EmptySection } from "~/app/[locale]/boards/_types";
import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client";
import type { MoveCategoryInput } from "./actions/move-category";
import { moveCategoryCallback } from "./actions/move-category";
import type { RemoveCategoryInput } from "./actions/remove-category";
@@ -40,7 +40,6 @@ export const useCategoryActions = () => {
kind: "category",
yOffset,
xOffset: 0,
collapsed: false,
items: [],
},
{
@@ -90,7 +89,6 @@ export const useCategoryActions = () => {
kind: "category",
yOffset: lastYOffset + 1,
xOffset: 0,
collapsed: false,
items: [],
},
{
@@ -1,9 +1,9 @@
import { Button, Group, Stack, TextInput } from "@mantine/core";
import { z } from "zod";
import { useZodForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { z } from "@homarr/validation";
interface Category {
id: string;
@@ -1,15 +1,12 @@
import { useCallback } from "react";
import { fetchApi } from "@homarr/api/client";
import { createId } from "@homarr/db/client";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import type { CategorySection } from "~/app/[locale]/boards/_types";
import { useCategoryActions } from "./category-actions";
import { CategoryEditModal } from "./category-edit-modal";
import { filterByItemKind } from "./filter";
export const useCategoryMenuActions = (category: CategorySection) => {
const { openModal } = useModalAction(CategoryEditModal);
@@ -100,29 +97,6 @@ export const useCategoryMenuActions = (category: CategorySection) => {
);
}, [category, openModal, renameCategory, t]);
const settings = useSettings();
const openAllInNewTabs = useCallback(async () => {
const appIds = filterByItemKind(category.items, settings, "app").map((item) => {
return item.options.appId;
});
const apps = await fetchApi.app.byIds.query(appIds);
const appsWithUrls = apps.filter((app) => app.href && app.href.length > 0);
for (const app of appsWithUrls) {
const openedWindow = window.open(app.href ?? undefined);
if (openedWindow) {
continue;
}
openConfirmModal({
title: t("section.category.openAllInNewTabs.title"),
children: t("section.category.openAllInNewTabs.text"),
});
break;
}
}, [category, t, openConfirmModal, settings]);
return {
addCategoryAbove,
addCategoryBelow,
@@ -130,6 +104,5 @@ export const useCategoryMenuActions = (category: CategorySection) => {
moveCategoryDown,
remove,
edit,
openAllInNewTabs,
};
};
@@ -5,7 +5,6 @@ import { ActionIcon, Menu } from "@mantine/core";
import {
IconDotsVertical,
IconEdit,
IconExternalLink,
IconRowInsertBottom,
IconRowInsertTop,
IconTransitionBottom,
@@ -13,12 +12,11 @@ import {
IconTrash,
} from "@tabler/icons-react";
import { useEditMode } from "@homarr/boards/edit-mode";
import type { MaybePromise } from "@homarr/common/types";
import { useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import type { CategorySection } from "~/app/[locale]/boards/_types";
import { useEditMode } from "~/app/[locale]/boards/(content)/_context";
import { useCategoryMenuActions } from "./category-menu-actions";
interface Props {
@@ -29,6 +27,8 @@ export const CategoryMenu = ({ category }: Props) => {
const actions = useActions(category);
const t = useScopedI18n("section.category");
if (actions.length === 0) return null;
return (
<Menu withArrow>
<Menu.Target>
@@ -37,20 +37,18 @@ export const CategoryMenu = ({ category }: Props) => {
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{actions.map((action) => {
return (
<React.Fragment key={action.label}>
{"group" in action && <Menu.Label>{t(action.group)}</Menu.Label>}
<Menu.Item
leftSection={<action.icon size="1rem" />}
onClick={action.onClick}
color={"color" in action ? action.color : undefined}
>
{t(action.label)}
</Menu.Item>
</React.Fragment>
);
})}
{actions.map((action) => (
<React.Fragment key={action.label}>
{"group" in action && <Menu.Label>{t(action.group)}</Menu.Label>}
<Menu.Item
leftSection={<action.icon size="1rem" />}
onClick={action.onClick}
color={"color" in action ? action.color : undefined}
>
{t(action.label)}
</Menu.Item>
</React.Fragment>
))}
</Menu.Dropdown>
</Menu>
);
@@ -108,21 +106,15 @@ const useEditModeActions = (category: CategorySection) => {
] as const satisfies ActionDefinition[];
};
const useNonEditModeActions = (category: CategorySection) => {
const { openAllInNewTabs } = useCategoryMenuActions(category);
return [
{
icon: IconExternalLink,
label: "action.openAllInNewTabs",
onClick: openAllInNewTabs,
},
] as const satisfies ActionDefinition[];
// TODO: once apps are added we can use this for the open many apps action
const useNonEditModeActions = (_category: CategorySection) => {
return [] as const satisfies ActionDefinition[];
};
interface ActionDefinition {
icon: TablerIcon;
label: string;
onClick: () => MaybePromise<void>;
onClick: () => void;
color?: string;
group?: string;
}
@@ -1,23 +0,0 @@
import type { WidgetKind } from "@homarr/definitions";
import type { SettingsContextProps } from "@homarr/settings";
import type { WidgetComponentProps } from "@homarr/widgets";
import { reduceWidgetOptionsWithDefaultValues } from "@homarr/widgets";
import type { Item } from "~/app/[locale]/boards/_types";
export const filterByItemKind = <TKind extends WidgetKind>(
items: Item[],
settings: SettingsContextProps,
kind: TKind,
) => {
return items
.filter((item) => item.kind === kind)
.map((item) => ({
...item,
options: reduceWidgetOptionsWithDefaultValues(
kind,
settings,
item.options,
) as WidgetComponentProps<TKind>["options"],
}));
};
@@ -1,8 +1,7 @@
import { useMemo } from "react";
import { useRequiredBoard } from "@homarr/boards/context";
import type { DynamicSection, Item, Section } from "~/app/[locale]/boards/_types";
import { useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
import { BoardItemContent } from "../items/item-content";
import { BoardDynamicSection } from "./dynamic-section";
import { GridStackItem } from "./gridstack/gridstack-item";
@@ -1,8 +1,7 @@
import { Box, Card } from "@mantine/core";
import { useRequiredBoard } from "@homarr/boards/context";
import type { DynamicSection } from "~/app/[locale]/boards/_types";
import { useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
import { BoardDynamicSectionMenu } from "./dynamic/dynamic-menu";
import { GridStack } from "./gridstack/gridstack";
import classes from "./item.module.css";
@@ -1,9 +1,9 @@
import { useCallback } from "react";
import { useUpdateBoard } from "@homarr/boards/updater";
import { createId } from "@homarr/db/client";
import type { DynamicSection, EmptySection } from "~/app/[locale]/boards/_types";
import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client";
interface RemoveDynamicSection {
id: string;
@@ -1,11 +1,11 @@
import { ActionIcon, Menu } from "@mantine/core";
import { IconDotsVertical, IconTrash } from "@tabler/icons-react";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useConfirmModal } from "@homarr/modals";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { DynamicSection } from "~/app/[locale]/boards/_types";
import { useEditMode } from "~/app/[locale]/boards/(content)/_context";
import { useDynamicSectionActions } from "./dynamic-actions";
export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSection }) => {
@@ -1,8 +1,7 @@
import combineClasses from "clsx";
import { useEditMode } from "@homarr/boards/edit-mode";
import type { EmptySection } from "~/app/[locale]/boards/_types";
import { useEditMode } from "~/app/[locale]/boards/(content)/_context";
import { GridStack } from "./gridstack/gridstack";
import { useSectionItems } from "./use-section-items";
@@ -2,12 +2,10 @@ import type { RefObject } from "react";
import { createRef, useCallback, useEffect, useRef } from "react";
import { useElementSize } from "@mantine/hooks";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import type { GridHTMLElement, GridItemHTMLElement, GridStack, GridStackNode } from "@homarr/gridstack";
import type { Section } from "~/app/[locale]/boards/_types";
import { useMarkSectionAsReady } from "~/app/[locale]/boards/(content)/_ready-context";
import { useEditMode, useMarkSectionAsReady, useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
import { useItemActions } from "../../items/item-actions";
import { useSectionActions } from "../section-actions";
import { initializeGridstack } from "./init-gridstack";
@@ -1,6 +1,6 @@
import { useCallback } from "react";
import { useUpdateBoard } from "@homarr/boards/updater";
import { useUpdateBoard } from "~/app/[locale]/boards/(content)/_client";
interface MoveAndResizeInnerSection {
innerSectionId: string;
@@ -1,6 +1,5 @@
import { useRequiredBoard } from "@homarr/boards/context";
import type { Section } from "~/app/[locale]/boards/_types";
import { useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
export const useSectionItems = (section: Section) => {
const board = useRequiredBoard();
@@ -1,5 +1,5 @@
import type { FocusEventHandler } from "react";
import { startTransition } from "react";
import { startTransition, useState } from "react";
import {
ActionIcon,
Box,
@@ -17,7 +17,7 @@ import {
UnstyledButton,
useCombobox,
} from "@mantine/core";
import { useDebouncedValue, useUncontrolled } from "@mantine/hooks";
import { useDebouncedValue } from "@mantine/hooks";
import { IconUpload } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
@@ -28,27 +28,17 @@ import { UploadMedia } from "~/app/[locale]/manage/medias/_actions/upload-media"
import classes from "./icon-picker.module.css";
interface IconPickerProps {
value?: string;
initialValue?: string;
onChange: (iconUrl: string) => void;
error?: string | null;
onFocus?: FocusEventHandler;
onBlur?: FocusEventHandler;
}
export const IconPicker = ({ value: propsValue, onChange, error, onFocus, onBlur }: IconPickerProps) => {
const [value, setValue] = useUncontrolled({
value: propsValue,
onChange,
});
const [search, setSearch] = useUncontrolled({
value,
onChange: (value) => {
setValue(value);
},
});
const [previewUrl, setPreviewUrl] = useUncontrolled({
value: propsValue ?? null,
});
export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: IconPickerProps) => {
const [value, setValue] = useState<string>(initialValue ?? "");
const [search, setSearch] = useState(initialValue ?? "");
const [previewUrl, setPreviewUrl] = useState<string | null>(initialValue ?? null);
const { data: session } = useSession();
const tCommon = useScopedI18n("common");
@@ -78,9 +68,10 @@ export const IconPicker = ({ value: propsValue, onChange, error, onFocus, onBlur
onClick={() => {
const value = item.url;
startTransition(() => {
setValue(value);
setPreviewUrl(value);
setSearch(value);
setValue(value);
onChange(value);
combobox.closeDropdown();
});
}}
@@ -137,6 +128,7 @@ export const IconPicker = ({ value: propsValue, onChange, error, onFocus, onBlur
setSearch(event.currentTarget.value);
setValue(event.currentTarget.value);
setPreviewUrl(null);
onChange(event.currentTarget.value);
}}
onClick={() => combobox.openDropdown()}
onFocus={(event) => {
@@ -162,6 +154,7 @@ export const IconPicker = ({ value: propsValue, onChange, error, onFocus, onBlur
setValue(url);
setPreviewUrl(url);
setSearch(url);
onChange(url);
});
}}
>
@@ -1,7 +1,7 @@
import { usePathname } from "next/navigation";
import type { AppShellProps } from "@mantine/core";
import { useOptionalBoard } from "@homarr/boards/context";
import { useOptionalBoard } from "~/app/[locale]/boards/(content)/_context";
const supportedVideoFormats = ["mp4", "webm", "ogg"];
const isVideo = (url: string) => supportedVideoFormats.some((format) => url.toLowerCase().endsWith(`.${format}`));
@@ -20,7 +20,6 @@ export const DesktopSearchInput = () => {
size="sm"
leftSection={<IconSearch size={20} stroke={1.5} />}
onClick={openSpotlight}
radius="xl"
>
{`${t("search.placeholder")}...`}
</TextInput>
@@ -1,7 +1,6 @@
"use client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useRequiredBoard } from "~/app/[locale]/boards/(content)/_context";
import { homarrLogoPath, homarrPageTitle } from "./homarr-logo";
import type { LogoWithTitleProps } from "./logo";
import { Logo, LogoWithTitle } from "./logo";
@@ -1,7 +1,7 @@
import type { JSX } from "react";
import { AppShellNavbar, AppShellSection, Image, ScrollArea } from "@mantine/core";
import { AppShellNavbar, AppShellSection, ScrollArea } from "@mantine/core";
import type { TablerIcon, TablerIconProps } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui";
import type { ClientNavigationLink } from "./navigation-link";
import { CommonNavLink } from "./navigation-link";
@@ -27,13 +27,8 @@ export const MainNavigation = ({ headerSection, footerSection, links }: MainNavi
return null;
}
const { icon: TablerIcon, iconProps, ...props } = link;
const Icon =
typeof TablerIcon === "string" ? (
<Image src={TablerIcon} w={20} h={20} />
) : (
<TablerIcon size={20} stroke={1.5} {...iconProps} />
);
const { icon: TablerIcon, ...props } = link;
const Icon = <TablerIcon size={20} stroke={1.5} />;
let clientLink: ClientNavigationLink;
if ("items" in props) {
clientLink = {
@@ -43,7 +38,7 @@ export const MainNavigation = ({ headerSection, footerSection, links }: MainNavi
.map((item) => {
return {
...item,
icon: <item.icon size={20} stroke={1.5} {...iconProps} />,
icon: <item.icon size={20} stroke={1.5} />,
};
}),
} as ClientNavigationLink;
@@ -60,8 +55,7 @@ export const MainNavigation = ({ headerSection, footerSection, links }: MainNavi
interface CommonNavigationLinkProps {
label: string;
icon: TablerIcon | string;
iconProps?: TablerIconProps;
icon: TablerIcon;
hidden?: boolean;
}
+7 -2
View File
@@ -12,7 +12,11 @@ export const env = createEnv({
* Specify your server-side environment variables schema here. This way you can ensure the app isn't
* built with invalid env vars.
*/
server: {},
server: {
// Comma separated list of docker hostnames that can be used to connect to query the docker endpoints (localhost:2375,host.docker.internal:2375, ...)
DOCKER_HOSTNAMES: z.string().optional(),
DOCKER_PORTS: z.string().optional(),
},
/**
* Specify your client-side environment variables schema here.
* For them to be exposed to the client, prefix them with `NEXT_PUBLIC_`.
@@ -26,8 +30,9 @@ export const env = createEnv({
runtimeEnv: {
PORT: process.env.PORT,
NODE_ENV: process.env.NODE_ENV,
DOCKER_HOSTNAMES: process.env.DOCKER_HOSTNAMES,
DOCKER_PORTS: process.env.DOCKER_PORTS,
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
},
skipValidation: shouldSkipEnvValidation(),
emptyStringAsUndefined: true,
});
Loaded 100 of 392 files, more files were not shown because too many files have changed in this diff. Show more