chore(release): automatic release v1.0.0

This commit is contained in:
homarr-releases[bot]
2025-01-10 19:12:41 +00:00
committed by GitHub
159 changed files with 10653 additions and 3758 deletions

2
.nvmrc
View File

@@ -1 +1 @@
22.12.0
22.13.0

View File

@@ -1,4 +1,4 @@
FROM node:22.12.0-alpine AS base
FROM node:22.13.0-alpine AS base
FROM base AS builder
RUN apk add --no-cache libc6-compat
@@ -12,9 +12,6 @@ COPY . .
RUN corepack enable pnpm && pnpm install --recursive --frozen-lockfile
# Install sharp for image optimization
RUN corepack enable pnpm && pnpm install sharp -w
# Copy static data as it is not part of the build
COPY static-data ./static-data
ARG SKIP_ENV_VALIDATION='true'
@@ -42,7 +39,7 @@ RUN mkdir -p /var/cache/nginx && \
touch /run/nginx/nginx.pid && \
mkdir -p /etc/nginx/templates /etc/nginx/ssl/certs
COPY --from=builder /app/apps/nextjs/next.config.mjs .
COPY --from=builder /app/apps/nextjs/next.config.ts .
COPY --from=builder /app/apps/nextjs/package.json .
COPY --from=builder /app/apps/tasks/tasks.cjs ./apps/tasks/tasks.cjs

View File

@@ -3,6 +3,7 @@ import "@homarr/auth/env.mjs";
import "@homarr/db/env.mjs";
import "@homarr/common/env.mjs";
import type { NextConfig } from "next";
import MillionLint from "@million/lint";
import createNextIntlPlugin from "next-intl/plugin";
@@ -11,14 +12,22 @@ import "./src/env.mjs";
// Package path does not work... so we need to use relative path
const withNextIntl = createNextIntlPlugin("../../packages/translation/src/request.ts");
/** @type {import("next").NextConfig} */
const nextConfig = {
interface WebpackConfig {
module: {
rules: {
test: RegExp;
loader: string;
}[];
};
}
const nextConfig: NextConfig = {
output: "standalone",
reactStrictMode: true,
/** We already do linting and typechecking as separate tasks in CI */
eslint: { ignoreDuringBuilds: true },
typescript: { ignoreBuildErrors: true },
webpack: (config, { isServer }) => {
webpack: (config: WebpackConfig, { isServer }) => {
if (isServer) {
config.module.rules.push({
test: /\.node$/,
@@ -38,6 +47,7 @@ const nextConfig = {
};
// Skip transform is used because of webpack loader, without it for example 'Tooltip.Floating' will not work and show an error
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const withMillionLint = MillionLint.next({ rsc: true, skipTransform: true, telemetry: false });
export default withNextIntl(nextConfig);

View File

@@ -6,7 +6,7 @@
"scripts": {
"build": "pnpm with-env next build",
"clean": "git clean -xdf .next .turbo node_modules",
"dev": "pnpm with-env next dev",
"dev": "pnpm with-env next dev --turbopack",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"start": "pnpm with-env next start",
@@ -23,7 +23,7 @@
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0",
"@homarr/gridstack": "^1.11.2",
"@homarr/gridstack": "^1.11.3",
"@homarr/integrations": "workspace:^0.1.0",
"@homarr/log": "workspace:^",
"@homarr/modals": "workspace:^0.1.0",
@@ -38,18 +38,18 @@
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "^7.15.2",
"@mantine/core": "^7.15.2",
"@mantine/dropzone": "^7.15.2",
"@mantine/hooks": "^7.15.2",
"@mantine/modals": "^7.15.2",
"@mantine/tiptap": "^7.15.2",
"@mantine/colors-generator": "^7.15.3",
"@mantine/core": "^7.15.3",
"@mantine/dropzone": "^7.15.3",
"@mantine/hooks": "^7.15.3",
"@mantine/modals": "^7.15.3",
"@mantine/tiptap": "^7.15.3",
"@million/lint": "1.0.14",
"@t3-oss/env-nextjs": "^0.11.1",
"@tabler/icons-react": "^3.26.0",
"@tanstack/react-query": "^5.62.12",
"@tanstack/react-query-devtools": "^5.62.12",
"@tanstack/react-query-next-experimental": "5.62.12",
"@tabler/icons-react": "^3.28.1",
"@tanstack/react-query": "^5.63.0",
"@tanstack/react-query-devtools": "^5.63.0",
"@tanstack/react-query-next-experimental": "5.63.0",
"@trpc/client": "next",
"@trpc/next": "next",
"@trpc/react-query": "next",
@@ -62,17 +62,17 @@
"dayjs": "^1.11.13",
"dotenv": "^16.4.7",
"flag-icons": "^7.2.3",
"glob": "^11.0.0",
"glob": "^11.0.1",
"jotai": "^2.11.0",
"mantine-react-table": "2.0.0-beta.7",
"next": "^14.2.22",
"mantine-react-table": "2.0.0-beta.8",
"next": "15.1.4",
"postcss-preset-mantine": "^1.17.0",
"prismjs": "^1.29.0",
"react": "^19.0.0",
"react-dom": "^19.0.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.83.0",
"sass": "^1.83.1",
"superjson": "2.2.2",
"swagger-ui-react": "^5.18.2",
"use-deep-compare-effect": "^1.8.1"
@@ -81,16 +81,16 @@
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "2.4.5",
"@types/chroma-js": "3.1.0",
"@types/node": "^22.10.5",
"@types/prismjs": "^1.26.5",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@types/react": "19.0.4",
"@types/react-dom": "19.0.2",
"@types/swagger-ui-react": "^4.18.3",
"concurrently": "^9.1.2",
"eslint": "^9.17.0",
"node-loader": "^2.1.0",
"prettier": "^3.4.2",
"typescript": "^5.7.2"
"typescript": "^5.7.3"
}
}

View File

@@ -18,7 +18,8 @@ import superjson from "superjson";
import type { SuperJSONResult } from "superjson";
import type { AppRouter } from "@homarr/api";
import { clientApi, createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/client";
import { clientApi, getTrpcUrl } from "@homarr/api/client";
import { createHeadersCallbackForSource } from "@homarr/api/shared";
import { env } from "~/env.mjs";

View File

@@ -11,15 +11,17 @@ import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { RegistrationForm } from "./_registration-form";
interface InviteUsagePageProps {
params: {
params: Promise<{
id: string;
};
searchParams: {
}>;
searchParams: Promise<{
token: string;
};
}>;
}
export default async function InviteUsagePage({ params, searchParams }: InviteUsagePageProps) {
export default async function InviteUsagePage(props: InviteUsagePageProps) {
const searchParams = await props.searchParams;
const params = await props.params;
if (!isProviderEnabled("credentials")) notFound();
const session = await auth();

View File

@@ -9,12 +9,13 @@ import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
import { LoginForm } from "./_login-form";
interface LoginProps {
searchParams: {
searchParams: Promise<{
callbackUrl?: string;
};
}>;
}
export default async function Login({ searchParams }: LoginProps) {
export default async function Login(props: LoginProps) {
const searchParams = await props.searchParams;
const session = await auth();
if (session) {

View File

@@ -31,15 +31,15 @@ import { GeneralSettingsContent } from "./_general";
import { LayoutSettingsContent } from "./_layout";
interface Props {
params: {
params: Promise<{
name: string;
};
searchParams: {
}>;
searchParams: Promise<{
tab?: keyof TranslationObject["board"]["setting"]["section"];
};
}>;
}
const getBoardAndPermissionsAsync = async (params: Props["params"]) => {
const getBoardAndPermissionsAsync = async (params: Awaited<Props["params"]>) => {
try {
const board = await api.board.getBoardByName({ name: params.name });
const { hasFullAccess } = await getBoardPermissionsAsync(board);
@@ -63,7 +63,9 @@ const getBoardAndPermissionsAsync = async (params: Props["params"]) => {
}
};
export default async function BoardSettingsPage({ params, searchParams }: Props) {
export default async function BoardSettingsPage(props: Props) {
const searchParams = await props.searchParams;
const params = await props.params;
const { board, permissions } = await getBoardAndPermissionsAsync(params);
const boardSettings = await getServerSettingByKeyAsync(db, "board");
const { hasFullAccess, hasChangeAccess } = await getBoardPermissionsAsync(board);

View File

@@ -28,9 +28,9 @@ export const createBoardLayout = <TParams extends Params>({
params,
children,
}: PropsWithChildren<{
params: TParams;
params: Promise<TParams>;
}>) => {
const initialBoard = await getInitialBoard(params).catch((error) => {
const initialBoard = await getInitialBoard(await params).catch((error) => {
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
logger.warn(error);
notFound();

View File

@@ -14,6 +14,7 @@ import { auth } from "@homarr/auth/next";
import { ModalProvider } from "@homarr/modals";
import { Notifications } from "@homarr/notifications";
import { SpotlightProvider } from "@homarr/spotlight";
import type { SupportedLanguage } from "@homarr/translation";
import { isLocaleRTL, isLocaleSupported } from "@homarr/translation";
import { getI18nMessages } from "@homarr/translation/server";
@@ -63,14 +64,17 @@ export const viewport: Viewport = {
],
};
export default async function Layout(props: { children: React.ReactNode; params: { locale: string } }) {
if (!isLocaleSupported(props.params.locale)) {
export default async function Layout(props: {
children: React.ReactNode;
params: Promise<{ locale: SupportedLanguage }>;
}) {
if (!isLocaleSupported((await props.params).locale)) {
notFound();
}
const session = await auth();
const colorScheme = await getCurrentColorSchemeAsync();
const direction = isLocaleRTL(props.params.locale) ? "rtl" : "ltr";
const direction = isLocaleRTL((await props.params).locale) ? "rtl" : "ltr";
const i18nMessages = await getI18nMessages();
const StackedProvider = composeWrappers([
@@ -89,7 +93,7 @@ export default async function Layout(props: { children: React.ReactNode; params:
return (
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
<html
lang={props.params.locale}
lang={(await props.params).locale}
dir={direction}
data-mantine-color-scheme={colorScheme}
style={{

View File

@@ -5,29 +5,29 @@ import { splitToNChunks } from "@homarr/common";
import classes from "./hero-banner.module.css";
const icons = [
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/homarr.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/sabnzbd.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/deluge.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/radarr.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/sonarr.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/lidarr.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sabnzbd.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/deluge.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/radarr.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sonarr.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/lidarr.svg",
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/pihole.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/dashdot.png",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/overseerr.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/plex.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/jellyfin.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/dashdot.png",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/overseerr.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jellyfin.svg",
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/homeassistant.svg",
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/freshrss.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/readarr.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/transmission.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/qbittorrent.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/nzbget.png",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/openmediavault.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/readarr.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/transmission.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/qbittorrent.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/nzbget.png",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/openmediavault.svg",
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/docker.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/jellyseerr.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jellyseerr.svg",
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/adguardhome.svg",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/tdarr.png",
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/prowlarr.svg",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/tdarr.png",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/prowlarr.svg",
];
const countIconGroups = 3;

View File

@@ -37,16 +37,16 @@ export async function generateMetadata() {
};
}
const getHost = () => {
const getHostAsync = async () => {
if (process.env.HOSTNAME) {
return `${process.env.HOSTNAME}:3000`;
}
return headers().get("host");
return (await headers()).get("host");
};
export default async function AboutPage() {
const baseServerUrl = `http://${getHost()}`;
const baseServerUrl = `http://${await getHostAsync()}`;
const t = await getScopedI18n("management.page.about");
const attributes = await getPackageAttributesAsync();
const githubContributors = (await fetch(`${baseServerUrl}/api/about/contributors/github`).then((res) =>

View File

@@ -9,10 +9,11 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { AppEditForm } from "./_app-edit-form";
interface AppEditPageProps {
params: { id: string };
params: Promise<{ id: string }>;
}
export default async function AppEditPage({ params }: AppEditPageProps) {
export default async function AppEditPage(props: AppEditPageProps) {
const params = await props.params;
const session = await auth();
if (!session?.user.permissions.includes("app-modify-all")) {

View File

@@ -6,7 +6,10 @@ import { IconBox, IconPencil } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
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";
@@ -14,22 +17,35 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NoResults } from "~/components/no-results";
import { AppDeleteButton } from "./_app-delete-button";
export default async function AppsPage() {
const searchParamsSchema = z.object({
search: z.string().optional(),
pageSize: z.string().regex(/\d+/).transform(Number).catch(10),
page: z.string().regex(/\d+/).transform(Number).catch(1),
});
interface AppsPageProps {
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
}
export default async function AppsPage(props: AppsPageProps) {
const session = await auth();
if (!session) {
redirect("/auth/login");
}
const apps = await api.app.all();
const searchParams = searchParamsSchema.parse(await props.searchParams);
const { items: apps, totalCount } = await api.app.getPaginated(searchParams);
const t = await getScopedI18n("app");
return (
<ManageContainer>
<DynamicBreadcrumb />
<Stack>
<Title>{t("page.list.title")}</Title>
<Group justify="space-between" align="center">
<Title>{t("page.list.title")}</Title>
<SearchInput placeholder={`${t("search")}...`} defaultValue={searchParams.search} />
{session.user.permissions.includes("app-create") && (
<MobileAffixButton component={Link} href="/manage/apps/new">
{t("page.create.title")}
@@ -44,6 +60,10 @@ export default async function AppsPage() {
))}
</Stack>
)}
<Group justify="end">
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
</Group>
</Stack>
</ManageContainer>
);

View File

@@ -3,12 +3,14 @@
import { useCallback } from "react";
import Link from "next/link";
import { Menu } from "@mantine/core";
import { IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
import { IconCopy, IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { DuplicateBoardModal } from "@homarr/modals-collection";
import { useScopedI18n } from "@homarr/translation/client";
import { useBoardPermissions } from "~/components/board/permissions/client";
@@ -30,8 +32,10 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
const tCommon = useScopedI18n("common");
const { hasFullAccess, hasChangeAccess } = useBoardPermissions(board);
const { data: session } = useSession();
const { openConfirmModal } = useConfirmModal();
const { openModal: openDuplicateModal } = useModalAction(DuplicateBoardModal);
const setHomeBoardMutation = clientApi.board.setHomeBoard.useMutation({
onSettled: async () => {
@@ -64,11 +68,28 @@ export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) =>
await setHomeBoardMutation.mutateAsync({ id: board.id });
}, [board.id, setHomeBoardMutation]);
const handleDuplicateBoard = useCallback(() => {
openDuplicateModal({
board: {
id: board.id,
name: board.name,
},
onSuccess: async () => {
await revalidatePathActionAsync("/manage/boards");
},
});
}, [board.id, board.name, openDuplicateModal]);
return (
<Menu.Dropdown>
<Menu.Item onClick={handleSetHomeBoard} leftSection={<IconHome {...iconProps} />}>
{t("setHomeBoard.label")}
</Menu.Item>
{session?.user.permissions.includes("board-create") && (
<Menu.Item onClick={handleDuplicateBoard} leftSection={<IconCopy {...iconProps} />}>
{t("duplicate.label")}
</Menu.Item>
)}
{hasChangeAccess && (
<>
<Menu.Divider />

View File

@@ -11,10 +11,11 @@ import { IntegrationAccessSettings } from "../../_components/integration-access-
import { EditIntegrationForm } from "./_integration-edit-form";
interface EditIntegrationPageProps {
params: { id: string };
params: Promise<{ id: string }>;
}
export default async function EditIntegrationPage({ params }: EditIntegrationPageProps) {
export default async function EditIntegrationPage(props: EditIntegrationPageProps) {
const params = await props.params;
const editT = await getScopedI18n("integration.page.edit");
const t = await getI18n();
const integration = await api.integration.byId({ id: params.id }).catch(catchTrpcNotFound);

View File

@@ -13,12 +13,15 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NewIntegrationForm } from "./_integration-new-form";
interface NewIntegrationPageProps {
searchParams: Partial<z.infer<typeof validation.integration.create>> & {
kind: IntegrationKind;
};
searchParams: Promise<
Partial<z.infer<typeof validation.integration.create>> & {
kind: IntegrationKind;
}
>;
}
export default async function IntegrationsNewPage({ searchParams }: NewIntegrationPageProps) {
export default async function IntegrationsNewPage(props: NewIntegrationPageProps) {
const searchParams = await props.searchParams;
const session = await auth();
if (!session?.user.permissions.includes("integration-create")) {
notFound();

View File

@@ -46,12 +46,13 @@ import { DeleteIntegrationActionButton } from "./_integration-buttons";
import { IntegrationCreateDropdownContent } from "./new/_integration-new-dropdown";
interface IntegrationsPageProps {
searchParams: {
searchParams: Promise<{
tab?: IntegrationKind;
};
}>;
}
export default async function IntegrationsPage({ searchParams }: IntegrationsPageProps) {
export default async function IntegrationsPage(props: IntegrationsPageProps) {
const searchParams = await props.searchParams;
const session = await auth();
if (!session) {

View File

@@ -1,15 +1,40 @@
"use client";
import type { JSX } from "react";
import { Button, FileButton } from "@mantine/core";
import { IconUpload } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import type { MaybePromise } from "@homarr/common/types";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { supportedMediaUploadFormats } from "@homarr/validation";
export const UploadMedia = () => {
export const UploadMediaButton = () => {
const t = useI18n();
const onSettledAsync = async () => {
await revalidatePathActionAsync("/manage/medias");
};
return (
<UploadMedia onSettled={onSettledAsync}>
{({ onClick, loading }) => (
<Button onClick={onClick} loading={loading} rightSection={<IconUpload size={16} stroke={1.5} />}>
{t("media.action.upload.label")}
</Button>
)}
</UploadMedia>
);
};
interface UploadMediaProps {
children: (props: { onClick: () => void; loading: boolean }) => JSX.Element;
onSettled?: () => MaybePromise<void>;
onSuccess?: (media: { id: string; url: string }) => MaybePromise<void>;
}
export const UploadMedia = ({ children, onSettled, onSuccess }: UploadMediaProps) => {
const t = useI18n();
const { mutateAsync, isPending } = clientApi.media.uploadMedia.useMutation();
@@ -18,10 +43,14 @@ export const UploadMedia = () => {
const formData = new FormData();
formData.append("file", file);
await mutateAsync(formData, {
onSuccess() {
async onSuccess(mediaId) {
showSuccessNotification({
message: t("media.action.upload.notification.success.message"),
});
await onSuccess?.({
id: mediaId,
url: `/api/user-medias/${mediaId}`,
});
},
onError() {
showErrorNotification({
@@ -29,18 +58,14 @@ export const UploadMedia = () => {
});
},
async onSettled() {
await revalidatePathActionAsync("/manage/medias");
await onSettled?.();
},
});
};
return (
<FileButton onChange={handleFileUploadAsync} accept={supportedMediaUploadFormats.join(",")}>
{({ onClick }) => (
<Button onClick={onClick} loading={isPending} rightSection={<IconUpload size={16} stroke={1.5} />}>
{t("media.action.upload.label")}
</Button>
)}
{({ onClick }) => children({ onClick, loading: isPending })}
</FileButton>
);
};

View File

@@ -7,6 +7,7 @@ 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 { getI18n } from "@homarr/translation/server";
import { SearchInput, TablePagination, UserAvatar } from "@homarr/ui";
import { z } from "@homarr/validation";
@@ -16,7 +17,7 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { CopyMedia } from "./_actions/copy-media";
import { DeleteMedia } from "./_actions/delete-media";
import { IncludeFromAllUsersSwitch } from "./_actions/show-all";
import { UploadMedia } from "./_actions/upload-media";
import { UploadMediaButton } from "./_actions/upload-media";
const searchParamsSchema = z.object({
search: z.string().optional(),
@@ -29,12 +30,8 @@ const searchParamsSchema = z.object({
page: z.string().regex(/\d+/).transform(Number).catch(1),
});
type SearchParamsSchemaInputFromSchema<TSchema extends Record<string, unknown>> = Partial<{
[K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[] ? string[] : string;
}>;
interface MediaListPageProps {
searchParams: SearchParamsSchemaInputFromSchema<z.infer<typeof searchParamsSchema>>;
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
}
export default async function GroupsListPage(props: MediaListPageProps) {
@@ -45,7 +42,7 @@ export default async function GroupsListPage(props: MediaListPageProps) {
}
const t = await getI18n();
const searchParams = searchParamsSchema.parse(props.searchParams);
const searchParams = searchParamsSchema.parse(await props.searchParams);
const { items: medias, totalCount } = await api.media.getPaginated(searchParams);
return (
@@ -61,7 +58,7 @@ export default async function GroupsListPage(props: MediaListPageProps) {
)}
</Group>
{session.user.permissions.includes("media-upload") && <UploadMedia />}
{session.user.permissions.includes("media-upload") && <UploadMediaButton />}
</Group>
<Table striped highlightOnHover>
<TableThead>

View File

@@ -10,10 +10,11 @@ import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { SearchEngineEditForm } from "./_search-engine-edit-form";
interface SearchEngineEditPageProps {
params: { id: string };
params: Promise<{ id: string }>;
}
export default async function SearchEngineEditPage({ params }: SearchEngineEditPageProps) {
export default async function SearchEngineEditPage(props: SearchEngineEditPageProps) {
const params = await props.params;
const session = await auth();
if (!session?.user.permissions.includes("search-engine-modify-all")) {

View File

@@ -6,6 +6,7 @@ import { IconPencil, IconSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
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";
@@ -22,12 +23,8 @@ const searchParamsSchema = z.object({
page: z.string().regex(/\d+/).transform(Number).catch(1),
});
type SearchParamsSchemaInputFromSchema<TSchema extends Record<string, unknown>> = Partial<{
[K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[] ? string[] : string;
}>;
interface SearchEnginesPageProps {
searchParams: SearchParamsSchemaInputFromSchema<z.infer<typeof searchParamsSchema>>;
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
}
export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
@@ -37,7 +34,7 @@ export default async function SearchEnginesPage(props: SearchEnginesPageProps) {
redirect("/auth/login");
}
const searchParams = searchParamsSchema.parse(props.searchParams);
const searchParams = searchParamsSchema.parse(await props.searchParams);
const { items: searchEngines, totalCount } = await api.searchEngine.getPaginated(searchParams);
const tEngine = await getScopedI18n("search.engine");

View File

@@ -0,0 +1,29 @@
"use client";
import { Select } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { ServerSettings } from "@homarr/server-settings";
import { useScopedI18n } from "@homarr/translation/client";
import { CommonSettingsForm } from "./common-form";
export const SearchSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["search"] }) => {
const tSearch = useScopedI18n("management.page.settings.section.search");
const [selectableSearchEngines] = clientApi.searchEngine.getSelectable.useSuspenseQuery({ withIntegrations: false });
return (
<CommonSettingsForm settingKey="search" defaultValues={defaultValues}>
{(form) => (
<>
<Select
label={tSearch("defaultSearchEngine.label")}
description={tSearch("defaultSearchEngine.description")}
data={selectableSearchEngines}
{...form.getInputProps("defaultSearchEngineId")}
/>
</>
)}
</CommonSettingsForm>
);
};

View File

@@ -11,6 +11,7 @@ import { AnalyticsSettings } from "./_components/analytics.settings";
import { AppearanceSettingsForm } from "./_components/appearance-settings-form";
import { BoardSettingsForm } from "./_components/board-settings-form";
import { CultureSettingsForm } from "./_components/culture-settings-form";
import { SearchSettingsForm } from "./_components/search-settings-form";
export async function generateMetadata() {
const t = await getScopedI18n("management");
@@ -41,6 +42,10 @@ export default async function SettingsPage() {
<Title order={2}>{tSettings("section.board.title")}</Title>
<BoardSettingsForm defaultValues={serverSettings.board} />
</Stack>
<Stack>
<Title order={2}>{tSettings("section.search.title")}</Title>
<SearchSettingsForm defaultValues={serverSettings.search} />
</Stack>
<Stack>
<Title order={2}>{tSettings("section.appearance.title")}</Title>
<AppearanceSettingsForm defaultValues={serverSettings.appearance} />

View File

@@ -30,7 +30,7 @@ export default async function ApiPage() {
if (!session?.user || !session.user.permissions.includes("admin")) {
notFound();
}
const document = openApiDocument(extractBaseUrlFromHeaders(headers()));
const document = openApiDocument(extractBaseUrlFromHeaders(await headers()));
const apiKeys = await api.apiKeys.getAll();
const t = await getScopedI18n("management.page.tool.api.tab");

View File

@@ -2,7 +2,14 @@
import type { MantineColor } from "@mantine/core";
import { Avatar, Badge, Box, Button, Group, Text } from "@mantine/core";
import { IconPlayerPlay, IconPlayerStop, IconRefresh, IconRotateClockwise, IconTrash } from "@tabler/icons-react";
import {
IconCategoryPlus,
IconPlayerPlay,
IconPlayerStop,
IconRefresh,
IconRotateClockwise,
IconTrash,
} from "@tabler/icons-react";
import type { MRT_ColumnDef } from "mantine-react-table";
import { MantineReactTable } from "mantine-react-table";
@@ -10,6 +17,8 @@ import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useTimeAgo } from "@homarr/common";
import type { DockerContainerState } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals";
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
@@ -125,6 +134,7 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
);
},
renderToolbarAlertBannerContent: ({ groupedAlert, table }) => {
const dockerContainers = table.getSelectedRowModel().rows.map((row) => row.original);
return (
<Group gap={"sm"}>
{groupedAlert}
@@ -134,7 +144,7 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
totalCount: table.getRowCount(),
})}
</Text>
<ContainerActionBar selectedIds={table.getSelectedRowModel().rows.map((row) => row.original.id)} />
<ContainerActionBar selectedContainers={dockerContainers} />
</Group>
);
},
@@ -151,16 +161,29 @@ export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"
}
interface ContainerActionBarProps {
selectedIds: string[];
selectedContainers: RouterOutputs["docker"]["getContainers"]["containers"];
}
const ContainerActionBar = ({ selectedIds }: ContainerActionBarProps) => {
const ContainerActionBar = ({ selectedContainers }: ContainerActionBarProps) => {
const t = useScopedI18n("docker.action");
const { openModal } = useModalAction(AddDockerAppToHomarr);
const handleClick = () => {
openModal({
selectedContainers,
});
};
const selectedIds = selectedContainers.map((container) => container.id);
return (
<Group gap="xs">
<ContainerActionBarButton icon={IconPlayerPlay} color="green" action="start" selectedIds={selectedIds} />
<ContainerActionBarButton icon={IconPlayerStop} color="red" action="stop" selectedIds={selectedIds} />
<ContainerActionBarButton icon={IconRotateClockwise} color="orange" action="restart" selectedIds={selectedIds} />
<ContainerActionBarButton icon={IconTrash} color="red" action="remove" selectedIds={selectedIds} />
<Button leftSection={<IconCategoryPlus />} color={"red"} onClick={handleClick} variant="light" radius="md">
{t("addToHomarr.label")}
</Button>
</Group>
);
};
@@ -174,9 +197,10 @@ interface ContainerActionBarButtonProps {
const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
const t = useScopedI18n("docker.action");
const { mutateAsync, isPending } = clientApi.docker[`${props.action}All`].useMutation();
const utils = clientApi.useUtils();
const { mutateAsync, isPending } = clientApi.docker[`${props.action}All`].useMutation();
const handleClickAsync = async () => {
await mutateAsync(
{ ids: props.selectedIds },

View File

@@ -0,0 +1,67 @@
"use client";
import { Button, Group, Select, Stack } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
interface ChangeDefaultSearchEngineFormProps {
user: RouterOutputs["user"]["getById"];
searchEnginesData: { value: string; label: string }[];
}
export const ChangeDefaultSearchEngineForm = ({ user, searchEnginesData }: ChangeDefaultSearchEngineFormProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.user.changeDefaultSearchEngine.useMutation({
async onSettled() {
await revalidatePathActionAsync(`/manage/users/${user.id}`);
},
onSuccess(_, variables) {
form.setInitialValues({
defaultSearchEngineId: variables.defaultSearchEngineId,
});
showSuccessNotification({
message: t("user.action.changeDefaultSearchEngine.notification.success.message"),
});
},
onError() {
showErrorNotification({
message: t("user.action.changeDefaultSearchEngine.notification.error.message"),
});
},
});
const form = useZodForm(validation.user.changeDefaultSearchEngine, {
initialValues: {
defaultSearchEngineId: user.defaultSearchEngineId ?? "",
},
});
const handleSubmit = (values: FormType) => {
mutate({
userId: user.id,
...values,
});
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="md">
<Select w="100%" data={searchEnginesData} {...form.getInputProps("defaultSearchEngineId")} />
<Group justify="end">
<Button type="submit" color="teal" loading={isPending}>
{t("common.action.save")}
</Button>
</Group>
</Stack>
</form>
);
};
type FormType = z.infer<typeof validation.user.changeDefaultSearchEngine>;

View File

@@ -11,6 +11,7 @@ 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 { DeleteUserButton } from "./_components/_delete-user-button";
import { FirstDayOfWeek } from "./_components/_first-day-of-week";
@@ -19,12 +20,13 @@ import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
import { UserProfileForm } from "./_components/_profile-form";
interface Props {
params: {
params: Promise<{
userId: string;
};
}>;
}
export async function generateMetadata({ params }: Props) {
export async function generateMetadata(props: Props) {
const params = await props.params;
const session = await auth();
const user = await api.user
.getById({
@@ -43,7 +45,8 @@ export async function generateMetadata({ params }: Props) {
};
}
export default async function EditUserPage({ params }: Props) {
export default async function EditUserPage(props: Props) {
const params = await props.params;
const t = await getI18n();
const tGeneral = await getScopedI18n("management.page.user.setting.general");
const session = await auth();
@@ -58,6 +61,7 @@ export default async function EditUserPage({ params }: Props) {
}
const boards = await api.board.getAllBoards();
const searchEngines = await api.searchEngine.getSelectable();
const isCredentialsUser = user.provider === "credentials";
@@ -95,6 +99,11 @@ export default async function EditUserPage({ params }: Props) {
/>
</Stack>
<Stack mb="lg">
<Title order={2}>{tGeneral("item.defaultSearchEngine")}</Title>
<ChangeDefaultSearchEngineForm user={user} searchEnginesData={searchEngines} />
</Stack>
<Stack mb="lg">
<Title order={2}>{tGeneral("item.firstDayOfWeek")}</Title>
<FirstDayOfWeek user={user} />

View File

@@ -15,10 +15,14 @@ import { NavigationLink } from "../groups/[id]/_navigation";
import { canAccessUserEditPage } from "./access";
interface LayoutProps {
params: { userId: string };
params: Promise<{ userId: string }>;
}
export default async function Layout({ children, params }: PropsWithChildren<LayoutProps>) {
export default async function Layout(props: PropsWithChildren<LayoutProps>) {
const params = await props.params;
const { children } = props;
const session = await auth();
const t = await getI18n();
const tUser = await getScopedI18n("management.page.user");

View File

@@ -10,12 +10,13 @@ import { canAccessUserEditPage } from "../access";
import { ChangePasswordForm } from "./_components/_change-password-form";
interface Props {
params: {
params: Promise<{
userId: string;
};
}>;
}
export default async function UserSecurityPage({ params }: Props) {
export default async function UserSecurityPage(props: Props) {
const params = await props.params;
const session = await auth();
const tSecurity = await getScopedI18n("management.page.user.setting.security");
const user = await api.user

View File

@@ -10,10 +10,14 @@ import { ManageContainer } from "~/components/manage/manage-container";
import { NavigationLink } from "./_navigation";
interface LayoutProps {
params: { id: string };
params: Promise<{ id: string }>;
}
export default async function Layout({ children, params }: PropsWithChildren<LayoutProps>) {
export default async function Layout(props: PropsWithChildren<LayoutProps>) {
const params = await props.params;
const { children } = props;
const t = await getI18n();
const tGroup = await getScopedI18n("management.page.group");
const group = await api.group.getById({ id: params.id });

View File

@@ -17,15 +17,17 @@ import { AddGroupMember } from "./_add-group-member";
import { RemoveGroupMember } from "./_remove-group-member";
interface GroupsDetailPageProps {
params: {
params: Promise<{
id: string;
};
searchParams: {
}>;
searchParams: Promise<{
search: string | undefined;
};
}>;
}
export default async function GroupsDetailPage({ params, searchParams }: GroupsDetailPageProps) {
export default async function GroupsDetailPage(props: GroupsDetailPageProps) {
const searchParams = await props.searchParams;
const params = await props.params;
const session = await auth();
if (!session?.user.permissions.includes("admin")) {

View File

@@ -14,12 +14,13 @@ import { ReservedGroupAlert } from "./_reserved-group-alert";
import { TransferGroupOwnership } from "./_transfer-group-ownership";
interface GroupsDetailPageProps {
params: {
params: Promise<{
id: string;
};
}>;
}
export default async function GroupsDetailPage({ params }: GroupsDetailPageProps) {
export default async function GroupsDetailPage(props: GroupsDetailPageProps) {
const params = await props.params;
const session = await auth();
if (!session?.user.permissions.includes("admin")) {

View File

@@ -12,12 +12,13 @@ import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { PermissionForm, PermissionSwitch, SaveAffix } from "./_group-permission-form";
interface GroupPermissionsPageProps {
params: {
params: Promise<{
id: string;
};
}>;
}
export default async function GroupPermissionsPage({ params }: GroupPermissionsPageProps) {
export default async function GroupPermissionsPage(props: GroupPermissionsPageProps) {
const params = await props.params;
const session = await auth();
if (!session?.user.permissions.includes("admin")) {

View File

@@ -5,6 +5,7 @@ import { Anchor, Group, Stack, Table, TableTbody, TableTd, TableTh, TableThead,
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
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";
@@ -19,12 +20,8 @@ const searchParamsSchema = z.object({
page: z.string().regex(/\d+/).transform(Number).catch(1),
});
type SearchParamsSchemaInputFromSchema<TSchema extends Record<string, unknown>> = Partial<{
[K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[] ? string[] : string;
}>;
interface GroupsListPageProps {
searchParams: SearchParamsSchemaInputFromSchema<z.infer<typeof searchParamsSchema>>;
searchParams: Promise<inferSearchParamsFromSchema<typeof searchParamsSchema>>;
}
export default async function GroupsListPage(props: GroupsListPageProps) {
@@ -35,7 +32,7 @@ export default async function GroupsListPage(props: GroupsListPageProps) {
}
const t = await getI18n();
const searchParams = searchParamsSchema.parse(props.searchParams);
const searchParams = searchParamsSchema.parse(await props.searchParams);
const { items: groups, totalCount } = await api.group.getPaginated(searchParams);
return (

View File

@@ -9,11 +9,11 @@ import { env } from "~/env.mjs";
import { WidgetPreviewPageContent } from "./_content";
interface Props {
params: { kind: string };
params: Promise<{ kind: string }>;
}
export default async function WidgetPreview(props: Props) {
if (!(props.params.kind in widgetImports || env.NODE_ENV !== "development")) {
if (!((await props.params).kind in widgetImports || env.NODE_ENV !== "development")) {
notFound();
}
@@ -26,7 +26,7 @@ export default async function WidgetPreview(props: Props) {
},
});
const sort = props.params.kind as WidgetKind;
const sort = (await props.params).kind as WidgetKind;
return (
<Center h="100vh">

View File

@@ -1,11 +1,10 @@
import { headers } from "next/headers";
import { userAgent } from "next/server";
import type { NextRequest } from "next/server";
import { userAgent } from "next/server";
import { createOpenApiFetchHandler } from "trpc-to-openapi";
import { appRouter, createTRPCContext } from "@homarr/api";
import { hashPasswordAsync } from "@homarr/auth";
import type { Session } from "@homarr/auth";
import { hashPasswordAsync } from "@homarr/auth";
import { createSessionAsync } from "@homarr/auth/server";
import { db, eq } from "@homarr/db";
import { apiKeys } from "@homarr/db/schema";
@@ -13,7 +12,7 @@ import { logger } from "@homarr/log";
const handlerAsync = async (req: NextRequest) => {
const apiKeyHeaderValue = req.headers.get("ApiKey");
const ipAddress = req.ip ?? headers().get("x-forwarded-for");
const ipAddress = req.headers.get("x-forwarded-for");
const { ua } = userAgent(req);
const session: Session | null = await getSessionOrDefaultFromHeadersAsync(apiKeyHeaderValue, ipAddress, ua);
@@ -88,9 +87,9 @@ const getSessionOrDefaultFromHeadersAsync = async (
};
export {
handlerAsync as DELETE,
handlerAsync as GET,
handlerAsync as PATCH,
handlerAsync as POST,
handlerAsync as PUT,
handlerAsync as DELETE,
handlerAsync as PATCH,
};

View File

@@ -1,16 +1,17 @@
import { NextRequest } from "next/server";
import { createHandlers } from "@homarr/auth";
import { createHandlersAsync } from "@homarr/auth";
import type { SupportedAuthProvider } from "@homarr/definitions";
import { logger } from "@homarr/log";
export const GET = async (req: NextRequest) => {
return await createHandlers(extractProvider(req), isSecureCookieEnabled(req)).handlers.GET(reqWithTrustedOrigin(req));
const { handlers } = await createHandlersAsync(extractProvider(req), isSecureCookieEnabled(req));
return await handlers.GET(reqWithTrustedOrigin(req));
};
export const POST = async (req: NextRequest) => {
return await createHandlers(extractProvider(req), isSecureCookieEnabled(req)).handlers.POST(
reqWithTrustedOrigin(req),
);
const { handlers } = await createHandlersAsync(extractProvider(req), isSecureCookieEnabled(req));
return await handlers.POST(reqWithTrustedOrigin(req));
};
/**

View File

@@ -5,7 +5,8 @@ import type { NextRequest } from "next/server";
import { db, eq } from "@homarr/db";
import { medias } from "@homarr/db/schema";
export async function GET(_req: NextRequest, { params }: { params: { id: string } }) {
export async function GET(_req: NextRequest, props: { params: Promise<{ id: string }> }) {
const params = await props.params;
const image = await db.query.medias.findFirst({
where: eq(medias.id, params.id),
columns: {

View File

@@ -36,6 +36,7 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
root: {
"--opacity": board.opacity / 100,
containerType: "size",
overflow: item.kind === "iframe" ? "hidden" : undefined,
},
}}
p={0}
@@ -87,7 +88,14 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
isEditMode={isEditMode}
boardId={board.id}
itemId={item.id}
setOptions={updateOptions}
setOptions={(partialNewOptions) =>
updateOptions({
newOptions: {
...partialNewOptions.newOptions,
...options,
},
})
}
{...dimensions}
/>
</ErrorBoundary>

View File

@@ -1,21 +1,67 @@
import { Button, Card, Center, Grid, Stack, Text } from "@mantine/core";
import { useMemo, useState } from "react";
import { Button, Card, Center, Grid, Input, Stack, Text } from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";
import { objectEntries } from "@homarr/common";
import type { WidgetKind } from "@homarr/definitions";
import { createModal } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { widgetImports } from "@homarr/widgets";
import type { WidgetDefinition } from "@homarr/widgets";
import { useItemActions } from "./item-actions";
export const ItemSelectModal = createModal<void>(({ actions }) => {
const [search, setSearch] = useState("");
const t = useI18n();
const { createItem } = useItemActions();
const items = useMemo(
() =>
objectEntries(widgetImports)
.map(([kind, value]) => ({
kind,
icon: value.definition.icon,
name: t(`widget.${kind}.name`),
description: t(`widget.${kind}.description`),
}))
.sort((itemA, itemB) => itemA.name.localeCompare(itemB.name)),
[t],
);
const filteredItems = useMemo(
() => items.filter((item) => item.name.toLowerCase().includes(search.toLowerCase())),
[items, search],
);
const handleAdd = (kind: WidgetKind) => {
createItem({ kind });
actions.closeModal();
};
return (
<Grid>
{objectEntries(widgetImports).map(([key, value]) => {
return <WidgetItem key={key} kind={key} definition={value.definition} closeModal={actions.closeModal} />;
})}
</Grid>
<Stack>
<Input
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
leftSection={<IconSearch />}
placeholder={`${t("item.create.search")}...`}
data-autofocus
onKeyDown={(event) => {
// Add item if there is only one item in the list and user presses Enter
if (event.key === "Enter" && filteredItems.length === 1) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
handleAdd(filteredItems[0]!.kind);
}
}}
/>
<Grid>
{filteredItems.map((item) => (
<WidgetItem key={item.kind} item={item} onSelect={() => handleAdd(item.kind)} />
))}
</Grid>
</Stack>
);
}).withOptions({
defaultTitle: (t) => t("item.create.title"),
@@ -23,20 +69,18 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
});
const WidgetItem = ({
kind,
definition,
closeModal,
item,
onSelect,
}: {
kind: WidgetKind;
definition: WidgetDefinition;
closeModal: () => void;
item: {
kind: WidgetKind;
name: string;
description: string;
icon: TablerIcon;
};
onSelect: () => void;
}) => {
const t = useI18n();
const { createItem } = useItemActions();
const handleAdd = (kind: WidgetKind) => {
createItem({ kind });
closeModal();
};
return (
<Grid.Col span={{ xs: 12, sm: 4, md: 3 }}>
@@ -44,25 +88,16 @@ const WidgetItem = ({
<Stack justify="space-between" h="100%">
<Stack gap="xs">
<Center>
<definition.icon />
<item.icon />
</Center>
<Text lh={1.2} style={{ whiteSpace: "normal" }} ta="center">
{t(`widget.${kind}.name`)}
{item.name}
</Text>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" ta="center" c="dimmed">
{t(`widget.${kind}.description`)}
{item.description}
</Text>
</Stack>
<Button
onClick={() => {
handleAdd(kind);
}}
variant="light"
size="xs"
mt="auto"
radius="md"
fullWidth
>
<Button onClick={onSelect} variant="light" size="xs" mt="auto" radius="md" fullWidth>
{t(`item.create.addToBoard`)}
</Button>
</Stack>

View File

@@ -1,10 +1,12 @@
import type { FocusEventHandler } from "react";
import { startTransition, useState } from "react";
import {
ActionIcon,
Box,
Card,
Combobox,
Flex,
Group,
Image,
Indicator,
InputBase,
@@ -16,10 +18,13 @@ import {
useCombobox,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconUpload } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useScopedI18n } from "@homarr/translation/client";
import { UploadMedia } from "~/app/[locale]/manage/medias/_actions/upload-media";
import classes from "./icon-picker.module.css";
interface IconPickerProps {
@@ -34,6 +39,7 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
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");
@@ -105,40 +111,61 @@ export const IconPicker = ({ initialValue, onChange, error, onFocus, onBlur }: I
return (
<Combobox store={combobox} withinPortal>
<Combobox.Target>
<InputBase
rightSection={<Combobox.Chevron />}
leftSection={
previewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={previewUrl} alt="" style={{ width: 20, height: 20 }} />
) : null
}
value={search}
onChange={(event) => {
combobox.openDropdown();
combobox.updateSelectedOptionIndex();
setSearch(event.currentTarget.value);
setValue(event.currentTarget.value);
setPreviewUrl(null);
onChange(event.currentTarget.value);
}}
onClick={() => combobox.openDropdown()}
onFocus={(event) => {
onFocus?.(event);
combobox.openDropdown();
}}
onBlur={(event) => {
onBlur?.(event);
combobox.closeDropdown();
setPreviewUrl(value);
setSearch(value || "");
}}
rightSectionPointerEvents="none"
withAsterisk
error={error}
label={tCommon("iconPicker.label")}
placeholder={tCommon("iconPicker.header", { countIcons: data?.countIcons ?? 0 })}
/>
<Group wrap="nowrap" gap="xs" w="100%" align="start">
<InputBase
flex={1}
rightSection={<Combobox.Chevron />}
leftSection={
previewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={previewUrl} alt="" style={{ width: 20, height: 20 }} />
) : null
}
value={search}
onChange={(event) => {
combobox.openDropdown();
combobox.updateSelectedOptionIndex();
setSearch(event.currentTarget.value);
setValue(event.currentTarget.value);
setPreviewUrl(null);
onChange(event.currentTarget.value);
}}
onClick={() => combobox.openDropdown()}
onFocus={(event) => {
onFocus?.(event);
combobox.openDropdown();
}}
onBlur={(event) => {
onBlur?.(event);
combobox.closeDropdown();
setPreviewUrl(value);
setSearch(value || "");
}}
rightSectionPointerEvents="none"
withAsterisk
error={error}
label={tCommon("iconPicker.label")}
placeholder={tCommon("iconPicker.header", { countIcons: data?.countIcons ?? 0 })}
/>
{session?.user.permissions.includes("media-upload") && (
<UploadMedia
onSuccess={({ url }) => {
startTransition(() => {
setValue(url);
setPreviewUrl(url);
setSearch(url);
onChange(url);
});
}}
>
{({ onClick, loading }) => (
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
<IconUpload size={16} stroke={1.5} />
</ActionIcon>
)}
</UploadMedia>
)}
</Group>
</Combobox.Target>
<Combobox.Dropdown>

View File

@@ -88,7 +88,7 @@ export const UserAvatarMenu = ({ children, availableUpdates }: UserAvatarMenuPro
</Menu.Item>
<Menu.Divider />
<Menu.Item p={0} closeMenuOnClick={false}>
<Menu.Item p={0} closeMenuOnClick={false} component="div">
<CurrentLanguageCombobox />
</Menu.Item>
<Menu.Divider />
@@ -113,7 +113,7 @@ export const UserAvatarMenu = ({ children, availableUpdates }: UserAvatarMenuPro
{t("logout")}
</Menu.Item>
) : (
<Menu.Item onClick={() => router.push("/auth/login")} leftSection={<IconLogin size="1rem" />}>
<Menu.Item component={Link} href="/auth/login" leftSection={<IconLogin size="1rem" />}>
{t("login")}
</Menu.Item>
)}

View File

@@ -4,7 +4,7 @@ import { createTRPCClient, httpLink } from "@trpc/client";
import SuperJSON from "superjson";
import type { AppRouter } from "@homarr/api";
import { createHeadersCallbackForSource } from "@homarr/api/client";
import { createHeadersCallbackForSource } from "@homarr/api/shared";
import { createI18nMiddleware } from "@homarr/translation/middleware";
export async function middleware(request: NextRequest) {

View File

@@ -7,7 +7,7 @@ import type { ColorScheme } from "@homarr/definitions";
import { colorSchemeCookieKey } from "@homarr/definitions";
export const getCurrentColorSchemeAsync = cache(async () => {
const cookieValue = cookies().get(colorSchemeCookieKey)?.value;
const cookieValue = (await cookies()).get(colorSchemeCookieKey)?.value;
if (cookieValue) {
return cookieValue as ColorScheme;

View File

@@ -38,7 +38,7 @@
"dayjs": "^1.11.13",
"dotenv": "^16.4.7",
"superjson": "2.2.2",
"undici": "7.2.0"
"undici": "7.2.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
@@ -49,6 +49,6 @@
"eslint": "^9.17.0",
"prettier": "^3.4.2",
"tsx": "4.19.2",
"typescript": "^5.7.2"
"typescript": "^5.7.3"
}
}

View File

@@ -36,6 +36,6 @@
"@types/ws": "^8.5.13",
"eslint": "^9.17.0",
"prettier": "^3.4.2",
"typescript": "^5.7.2"
"typescript": "^5.7.3"
}
}

View File

@@ -43,18 +43,18 @@
"@vitest/ui": "^2.1.8",
"conventional-changelog-conventionalcommits": "^8.0.0",
"cross-env": "^7.0.3",
"jsdom": "^25.0.1",
"jsdom": "^26.0.0",
"prettier": "^3.4.2",
"semantic-release": "^24.2.1",
"testcontainers": "^10.16.0",
"turbo": "^2.3.3",
"typescript": "^5.7.2",
"typescript": "^5.7.3",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^2.1.8"
},
"packageManager": "pnpm@9.15.2",
"packageManager": "pnpm@9.15.3",
"engines": {
"node": ">=22.12.0"
"node": ">=22.13.0"
},
"pnpm": {
"allowNonAppliedPatches": true,

View File

@@ -33,6 +33,6 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"typescript": "^5.7.2"
"typescript": "^5.7.3"
}
}

View File

@@ -8,7 +8,8 @@
".": "./src/index.ts",
"./client": "./src/client.ts",
"./server": "./src/server.ts",
"./websocket": "./src/websocket.ts"
"./websocket": "./src/websocket.ts",
"./shared": "./src/shared.ts"
},
"main": "./index.ts",
"types": "./index.ts",
@@ -40,20 +41,21 @@
"@trpc/client": "next",
"@trpc/react-query": "next",
"@trpc/server": "next",
"dockerode": "^4.0.2",
"dockerode": "^4.0.3",
"lodash.clonedeep": "^4.5.0",
"next": "^14.2.22",
"react": "^19.0.0",
"next": "15.1.4",
"react": "19.0.0",
"react-dom": "19.0.0",
"superjson": "2.2.2",
"trpc-to-openapi": "^2.1.1"
"trpc-to-openapi": "^2.1.2"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/dockerode": "^3.3.33",
"@types/dockerode": "^3.3.34",
"eslint": "^9.17.0",
"prettier": "^3.4.2",
"typescript": "^5.7.2"
"typescript": "^5.7.3"
}
}

View File

@@ -1,7 +1,10 @@
"use client";
import { createTRPCClient, createTRPCReact, httpLink } from "@trpc/react-query";
import SuperJSON from "superjson";
import type { AppRouter } from ".";
import { createHeadersCallbackForSource } from "./shared";
export const clientApi = createTRPCReact<AppRouter>();
export const fetchApi = createTRPCClient<AppRouter>({
@@ -26,42 +29,3 @@ function getBaseUrl() {
export function getTrpcUrl() {
return `${getBaseUrl()}/api/trpc`;
}
/**
* Creates a headers callback for a given source
* It will set the x-trpc-source header and cookies if needed
* @param source trpc source request comes from
* @returns headers callback
*/
export function createHeadersCallbackForSource(source: string) {
return async () => {
const headers = new Headers();
headers.set("x-trpc-source", source);
const cookies = await importCookiesAsync();
// We need to set cookie for ssr requests (for example with useSuspenseQuery or middleware)
if (cookies) {
headers.set("cookie", cookies);
}
return headers;
};
}
/**
* This is a workarround as cookies are not passed to the server
* when using useSuspenseQuery or middleware
* @returns cookie string on server or null on client
*/
async function importCookiesAsync() {
if (typeof window === "undefined") {
return await import("next/headers").then(({ cookies }) =>
cookies()
.getAll()
.map(({ name, value }) => `${name}=${value}`)
.join(";"),
);
}
return null;
}

View File

@@ -3,13 +3,36 @@ import { TRPCError } from "@trpc/server";
import { asc, createId, eq, inArray, like } from "@homarr/db";
import { apps } from "@homarr/db/schema";
import { selectAppSchema } from "@homarr/db/validationSchemas";
import { getIconForName } from "@homarr/icons";
import { validation, z } from "@homarr/validation";
import { convertIntersectionToZodObject } from "../schema-merger";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
import { canUserSeeAppAsync } from "./app/app-access-control";
const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
export const appRouter = createTRPCRouter({
getPaginated: protectedProcedure
.input(validation.common.paginated)
.output(z.object({ items: z.array(selectAppSchema), totalCount: z.number() }))
.meta({ openapi: { method: "GET", path: "/api/apps/paginated", tags: ["apps"], protect: true } })
.query(async ({ input, ctx }) => {
const whereQuery = input.search ? like(apps.name, `%${input.search.trim()}%`) : undefined;
const totalCount = await ctx.db.$count(apps, whereQuery);
const dbApps = await ctx.db.query.apps.findMany({
limit: input.pageSize,
offset: (input.page - 1) * input.pageSize,
where: whereQuery,
orderBy: asc(apps.name),
});
return {
items: dbApps,
totalCount,
};
}),
all: protectedProcedure
.input(z.void())
.output(z.array(selectAppSchema))
@@ -98,6 +121,21 @@ export const appRouter = createTRPCRouter({
href: input.href,
});
}),
createMany: permissionRequiredProcedure
.requiresPermission("app-create")
.input(validation.app.createMany)
.output(z.void())
.mutation(async ({ ctx, input }) => {
await ctx.db.insert(apps).values(
input.map((app) => ({
id: createId(),
name: app.name,
description: app.description,
iconUrl: app.iconUrl ?? getIconForName(ctx.db, app.name).sync()?.url ?? defaultIcon,
href: app.href,
})),
);
}),
update: permissionRequiredProcedure
.requiresPermission("app-modify-all")
.input(convertIntersectionToZodObject(validation.app.edit))

View File

@@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server";
import superjson from "superjson";
import { constructBoardPermissions } from "@homarr/auth/shared";
import type { Database, SQL } from "@homarr/db";
import type { Database, InferInsertModel, SQL } from "@homarr/db";
import { and, createId, eq, inArray, like, or } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import {
@@ -11,7 +11,9 @@ import {
boardUserPermissions,
groupMembers,
groupPermissions,
integrationGroupPermissions,
integrationItems,
integrationUserPermissions,
items,
sections,
users,
@@ -216,6 +218,111 @@ export const boardRouter = createTRPCRouter({
});
});
}),
duplicateBoard: permissionRequiredProcedure
.requiresPermission("board-create")
.input(validation.board.duplicate)
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
await noBoardWithSimilarNameAsync(ctx.db, input.name);
const board = await ctx.db.query.boards.findFirst({
where: eq(boards.id, input.id),
with: {
sections: {
with: {
items: {
with: {
integrations: true,
},
},
},
},
},
});
if (!board) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Board not found",
});
}
const { sections: boardSections, ...boardProps } = board;
const newBoardId = createId();
const sectionMap = new Map<string, string>(boardSections.map((section) => [section.id, createId()]));
const sectionsToInsert: InferInsertModel<typeof sections>[] = boardSections.map(({ items: _, ...section }) => ({
...section,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
id: sectionMap.get(section.id)!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
parentSectionId: section.parentSectionId ? sectionMap.get(section.parentSectionId)! : null,
boardId: newBoardId,
}));
const flatItems = boardSections.flatMap((section) => section.items);
const itemMap = new Map<string, string>(flatItems.map((item) => [item.id, createId()]));
const itemsToInsert: InferInsertModel<typeof items>[] = flatItems.map(({ integrations: _, ...item }) => ({
...item,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
id: itemMap.get(item.id)!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sectionId: sectionMap.get(item.sectionId)!,
}));
// Creates a list with all integration ids the user has access to
const hasAccessForAll = ctx.session.user.permissions.includes("integration-use-all");
const integrationIdsWithAccess = hasAccessForAll
? []
: await ctx.db
.selectDistinct({
id: integrationGroupPermissions.integrationId,
})
.from(integrationGroupPermissions)
.leftJoin(groupMembers, eq(integrationGroupPermissions.groupId, groupMembers.groupId))
.where(eq(groupMembers.userId, ctx.session.user.id))
.union(
ctx.db
.selectDistinct({ id: integrationUserPermissions.integrationId })
.from(integrationUserPermissions)
.where(eq(integrationUserPermissions.userId, ctx.session.user.id)),
)
.then((result) => result.map((row) => row.id));
const itemIntegrationsToInsert = flatItems.flatMap((item) =>
item.integrations
// Restrict integrations to only those the user has access to
.filter(({ integrationId }) => integrationIdsWithAccess.includes(integrationId) || hasAccessForAll)
.map((integration) => ({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
itemId: itemMap.get(item.id)!,
integrationId: integration.integrationId,
})),
);
ctx.db.transaction((transaction) => {
transaction
.insert(boards)
.values({
...boardProps,
id: newBoardId,
name: input.name,
creatorId: ctx.session.user.id,
})
.run();
if (sectionsToInsert.length > 0) {
transaction.insert(sections).values(sectionsToInsert).run();
}
if (itemsToInsert.length > 0) {
transaction.insert(items).values(itemsToInsert).run();
}
if (itemIntegrationsToInsert.length > 0) {
transaction.insert(integrationItems).values(itemIntegrationsToInsert).run();
}
});
}),
renameBoard: protectedProcedure.input(validation.board.rename).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");

View File

@@ -1,12 +1,13 @@
import { TRPCError } from "@trpc/server";
import { createId, eq, like, sql } from "@homarr/db";
import { searchEngines } from "@homarr/db/schema";
import { asc, createId, eq, like, sql } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import { searchEngines, users } from "@homarr/db/schema";
import { integrationCreator } from "@homarr/integrations";
import { validation } from "@homarr/validation";
import { validation, z } from "@homarr/validation";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../../trpc";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
export const searchEngineRouter = createTRPCRouter({
getPaginated: protectedProcedure.input(validation.common.paginated).query(async ({ input, ctx }) => {
@@ -29,6 +30,21 @@ export const searchEngineRouter = createTRPCRouter({
totalCount: searchEngineCount[0]?.count ?? 0,
};
}),
getSelectable: protectedProcedure
.input(z.object({ withIntegrations: z.boolean() }).default({ withIntegrations: true }))
.query(async ({ ctx, input }) => {
return await ctx.db.query.searchEngines
.findMany({
orderBy: asc(searchEngines.name),
where: input.withIntegrations ? undefined : eq(searchEngines.type, "generic"),
columns: {
id: true,
name: true,
},
})
.then((engines) => engines.map((engine) => ({ value: engine.id, label: engine.name })));
}),
byId: protectedProcedure.input(validation.common.byId).query(async ({ ctx, input }) => {
const searchEngine = await ctx.db.query.searchEngines.findFirst({
where: eq(searchEngines.id, input.id),
@@ -55,6 +71,54 @@ export const searchEngineRouter = createTRPCRouter({
urlTemplate: searchEngine.urlTemplate!,
};
}),
getDefaultSearchEngine: publicProcedure.query(async ({ ctx }) => {
const userDefaultId = ctx.session?.user.id
? ((await ctx.db.query.users
.findFirst({
where: eq(users.id, ctx.session.user.id),
columns: {
defaultSearchEngineId: true,
},
})
.then((user) => user?.defaultSearchEngineId)) ?? null)
: null;
if (userDefaultId) {
return await ctx.db.query.searchEngines.findFirst({
where: eq(searchEngines.id, userDefaultId),
with: {
integration: {
columns: {
kind: true,
url: true,
id: true,
},
},
},
});
}
const serverDefaultId = await getServerSettingByKeyAsync(ctx.db, "search").then(
(setting) => setting.defaultSearchEngineId,
);
if (serverDefaultId) {
return await ctx.db.query.searchEngines.findFirst({
where: eq(searchEngines.id, serverDefaultId),
with: {
integration: {
columns: {
kind: true,
url: true,
id: true,
},
},
},
});
}
return null;
}),
search: protectedProcedure.input(validation.common.search).query(async ({ ctx, input }) => {
return await ctx.db.query.searchEngines.findMany({
where: like(searchEngines.short, `${input.query.toLowerCase().trim()}%`),

View File

@@ -240,7 +240,7 @@ describe("create should create a new integration", () => {
expect(dbSearchEngine!.short).toBe("j");
expect(dbSearchEngine!.name).toBe(input.name);
expect(dbSearchEngine!.iconUrl).toBe(
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyseerr.png",
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/jellyseerr.png",
);
});

View File

@@ -211,6 +211,7 @@ export const userRouter = createTRPCRouter({
homeBoardId: true,
firstDayOfWeek: true,
pingIconsEnabled: true,
defaultSearchEngineId: true,
}),
)
.meta({ openapi: { method: "GET", path: "/api/users/{userId}", tags: ["users"], protect: true } })
@@ -233,6 +234,7 @@ export const userRouter = createTRPCRouter({
homeBoardId: true,
firstDayOfWeek: true,
pingIconsEnabled: true,
defaultSearchEngineId: true,
},
where: eq(users.id, input.userId),
});
@@ -406,6 +408,43 @@ export const userRouter = createTRPCRouter({
})
.where(eq(users.id, input.userId));
}),
changeDefaultSearchEngine: protectedProcedure
.input(
convertIntersectionToZodObject(validation.user.changeDefaultSearchEngine.and(z.object({ userId: z.string() }))),
)
.output(z.void())
.meta({ openapi: { method: "PATCH", path: "/api/users/changeSearchEngine", tags: ["users"], protect: true } })
.mutation(async ({ input, ctx }) => {
const user = ctx.session.user;
// Only admins can change other users passwords
if (!user.permissions.includes("admin") && user.id !== input.userId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
const dbUser = await ctx.db.query.users.findFirst({
columns: {
id: true,
},
where: eq(users.id, input.userId),
});
if (!dbUser) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
await ctx.db
.update(users)
.set({
defaultSearchEngineId: input.defaultSearchEngineId,
})
.where(eq(users.id, input.userId));
}),
changeColorScheme: protectedProcedure
.input(validation.user.changeColorScheme)
.output(z.void())

View File

@@ -9,7 +9,7 @@ import { auth } from "@homarr/auth/next";
* handling a tRPC call from a React Server Component.
*/
const createContext = cache(async () => {
const heads = new Headers(headers());
const heads = new Headers(await headers());
heads.set("x-trpc-source", "rsc");
return createTRPCContext({

View File

@@ -0,0 +1,38 @@
/**
* Creates a headers callback for a given source
* It will set the x-trpc-source header and cookies if needed
* @param source trpc source request comes from
* @returns headers callback
*/
export function createHeadersCallbackForSource(source: string) {
return async () => {
const headers = new Headers();
headers.set("x-trpc-source", source);
const cookies = await importCookiesAsync();
// We need to set cookie for ssr requests (for example with useSuspenseQuery or middleware)
if (cookies) {
headers.set("cookie", cookies);
}
return headers;
};
}
/**
* This is a workarround as cookies are not passed to the server
* when using useSuspenseQuery or middleware
* @returns cookie string on server or null on client
*/
async function importCookiesAsync() {
if (typeof window !== "undefined") {
return null;
}
const { cookies } = await import("next/headers");
return (await cookies())
.getAll()
.map(({ name, value }) => `${name}=${value}`)
.join(";");
}

View File

@@ -74,7 +74,7 @@ export const createConfiguration = (
userId: user.id,
});
cookies().set(sessionTokenCookieName, sessionToken, {
(await cookies()).set(sessionTokenCookieName, sessionToken, {
path: "/",
expires: expires,
httpOnly: true,
@@ -99,8 +99,9 @@ export const createConfiguration = (
error: "/auth/login",
},
jwt: {
encode() {
const cookie = cookies().get(sessionTokenCookieName)?.value;
// eslint-disable-next-line no-restricted-syntax
async encode() {
const cookie = (await cookies()).get(sessionTokenCookieName)?.value;
return cookie ?? "";
},

View File

@@ -74,6 +74,7 @@ export const env = createEnv({
AUTH_OIDC_AUTO_LOGIN: booleanSchema,
AUTH_OIDC_SCOPE_OVERWRITE: z.string().min(1).default("openid email profile groups"),
AUTH_OIDC_GROUPS_ATTRIBUTE: z.string().default("groups"), // Is used in the signIn event to assign the correct groups, key is from object of decoded id_token
AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: z.string().optional(),
}
: {}),
...(authProviders.includes("ldap")
@@ -117,6 +118,7 @@ export const env = createEnv({
AUTH_LDAP_USER_MAIL_ATTRIBUTE: process.env.AUTH_LDAP_USER_MAIL_ATTRIBUTE,
AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG: process.env.AUTH_LDAP_USERNAME_FILTER_EXTRA_ARG,
AUTH_OIDC_AUTO_LOGIN: process.env.AUTH_OIDC_AUTO_LOGIN,
AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: process.env.AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE,
},
skipValidation,
});

View File

@@ -9,6 +9,7 @@ import { colorSchemeCookieKey, everyoneGroup } from "@homarr/definitions";
import { logger } from "@homarr/log";
import { env } from "./env.mjs";
import { extractProfileName } from "./providers/oidc/oidc-provider";
export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["events"], undefined>["signIn"] => {
return async ({ user, profile }) => {
@@ -43,18 +44,24 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
);
}
const profileUsername = profile?.preferred_username?.includes("@") ? profile.name : profile?.preferred_username;
if (profileUsername && dbUser.name !== profileUsername) {
await db.update(users).set({ name: profileUsername }).where(eq(users.id, user.id));
logger.info(
`Username for user of oidc provider has changed. user=${user.id} old='${dbUser.name}' new='${profileUsername}'`,
);
if (profile) {
const profileUsername = extractProfileName(profile);
if (!profileUsername) {
throw new Error(`OIDC provider did not return a name properties='${Object.keys(profile).join(",")}'`);
}
if (dbUser.name !== profileUsername) {
await db.update(users).set({ name: profileUsername }).where(eq(users.id, user.id));
logger.info(
`Username for user of oidc provider has changed. user=${user.id} old='${dbUser.name}' new='${profileUsername}'`,
);
}
}
logger.info(`User '${dbUser.name}' logged in at ${dayjs().format()}`);
// We use a cookie as localStorage is not shared with server (otherwise flickering would occur)
cookies().set(colorSchemeCookieKey, dbUser.colorScheme, {
(await cookies()).set(colorSchemeCookieKey, dbUser.colorScheme, {
path: "/",
expires: dayjs().add(1, "year").toDate(),
});

View File

@@ -20,7 +20,7 @@ declare module "next-auth" {
export * from "./security";
// See why it's unknown in the [...nextauth]/route.ts file
export const createHandlers = (provider: SupportedAuthProvider | "unknown", useSecureCookies: boolean) =>
createConfiguration(provider, headers(), useSecureCookies);
export const createHandlersAsync = async (provider: SupportedAuthProvider | "unknown", useSecureCookies: boolean) =>
createConfiguration(provider, await headers(), useSecureCookies);
export { getSessionFromTokenAsync as getSessionFromToken, sessionTokenCookieName } from "./session";

View File

@@ -33,11 +33,11 @@
"@t3-oss/env-nextjs": "^0.11.1",
"bcrypt": "^5.1.1",
"cookies": "^0.9.1",
"ldapts": "7.3.0",
"next": "^14.2.22",
"ldapts": "7.3.1",
"next": "15.1.4",
"next-auth": "5.0.0-beta.25",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react": "19.0.0",
"react-dom": "19.0.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
@@ -47,6 +47,6 @@
"@types/cookies": "0.9.0",
"eslint": "^9.17.0",
"prettier": "^3.4.2",
"typescript": "^5.7.2"
"typescript": "^5.7.3"
}
}

View File

@@ -1,18 +1,10 @@
import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
import type { OIDCConfig } from "next-auth/providers";
import type { OIDCConfig } from "@auth/core/providers";
import type { Profile } from "@auth/core/types";
import { env } from "../../env.mjs";
import { createRedirectUri } from "../../redirect";
interface Profile {
sub: string;
name: string;
email: string;
groups: string[];
preferred_username: string;
email_verified: boolean;
}
export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profile> => ({
id: "oidc",
name: env.AUTH_OIDC_CLIENT_NAME,
@@ -28,12 +20,28 @@ export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profil
},
},
profile(profile) {
if (!profile.sub) {
throw new Error(`OIDC provider did not return a sub property='${Object.keys(profile).join(",")}'`);
}
const name = extractProfileName(profile);
if (!name) {
throw new Error(`OIDC provider did not return a name properties='${Object.keys(profile).join(",")}'`);
}
return {
id: profile.sub,
// Use the name as the username if the preferred_username is an email address
name: profile.preferred_username.includes("@") ? profile.name : profile.preferred_username,
name,
email: profile.email,
provider: "oidc",
};
},
});
export const extractProfileName = (profile: Profile) => {
if (!env.AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE) {
// Use the name as the username if the preferred_username is an email address
return profile.preferred_username?.includes("@") ? profile.name : profile.preferred_username;
}
return profile[env.AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE as keyof typeof profile] as string;
};

View File

@@ -29,7 +29,7 @@ vi.mock("next/headers", async (importOriginal) => {
vi.spyOn(result, "set");
const cookies = () => result;
const cookies = () => Promise.resolve(result);
return { ...mod, cookies } satisfies HeadersExport;
});
@@ -238,7 +238,7 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
});
// Assert
expect(cookies().set).toHaveBeenCalledWith(
expect((await cookies()).set).toHaveBeenCalledWith(
colorSchemeCookieKey,
"dark",
expect.objectContaining({

View File

@@ -34,6 +34,6 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"typescript": "^5.7.2"
"typescript": "^5.7.3"
}
}

View File

@@ -28,14 +28,15 @@
"dependencies": {
"@homarr/log": "workspace:^0.1.0",
"dayjs": "^1.11.13",
"next": "^14.2.22",
"react": "^19.0.0"
"next": "15.1.4",
"react": "19.0.0",
"react-dom": "19.0.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"typescript": "^5.7.2"
"typescript": "^5.7.3"
}
}

View File

@@ -1,3 +1,5 @@
import type { z } from "zod";
export type MaybePromise<T> = T | Promise<T>;
export type AtLeastOneOf<T> = [T, ...T[]];
@@ -16,3 +18,11 @@ export type Inverse<T extends Invertible> = {
};
type Invertible = Record<PropertyKey, PropertyKey>;
export type inferSearchParamsFromSchema<TSchema extends z.AnyZodObject> = inferSearchParamsFromSchemaInner<
z.infer<TSchema>
>;
type inferSearchParamsFromSchemaInner<TSchema extends Record<string, unknown>> = Partial<{
[K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[] ? string[] : string;
}>;

View File

@@ -31,6 +31,6 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"typescript": "^5.7.2"
"typescript": "^5.7.3"
}
}

View File

@@ -30,6 +30,6 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"typescript": "^5.7.2"
"typescript": "^5.7.3"
}
}

View File

@@ -33,6 +33,6 @@
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node-cron": "^3.0.11",
"eslint": "^9.17.0",
"typescript": "^5.7.2"
"typescript": "^5.7.3"
}
}

View File

@@ -46,6 +46,6 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"typescript": "^5.7.2"
"typescript": "^5.7.3"
}
}

View File

@@ -1,7 +1,7 @@
import { splitToNChunks, Stopwatch } from "@homarr/common";
import { EVERY_WEEK } from "@homarr/cron-jobs-core/expressions";
import type { InferInsertModel } from "@homarr/db";
import { db, inArray } from "@homarr/db";
import { db, inArray, sql } from "@homarr/db";
import { createId } from "@homarr/db/client";
import { iconRepositories, icons } from "@homarr/db/schema";
import { fetchIconsAsync } from "@homarr/icons";
@@ -22,13 +22,13 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
`Successfully fetched ${countIcons} icons from ${repositoryIconGroups.length} repositories within ${stopWatch.getElapsedInHumanWords()}`,
);
const databaseIconGroups = await db.query.iconRepositories.findMany({
const databaseIconRepositories = await db.query.iconRepositories.findMany({
with: {
icons: true,
},
});
const skippedChecksums: string[] = [];
const skippedChecksums: `${string}.${string}`[] = [];
let countDeleted = 0;
let countInserted = 0;
@@ -43,18 +43,24 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
continue;
}
const repositoryInDb = databaseIconGroups.find((dbIconGroup) => dbIconGroup.slug === repositoryIconGroup.slug);
const repositoryIconGroupId: string = repositoryInDb?.id ?? createId();
const repositoryInDb = databaseIconRepositories.find(
(dbIconGroup) => dbIconGroup.slug === repositoryIconGroup.slug,
);
const iconRepositoryId: string = repositoryInDb?.id ?? createId();
if (!repositoryInDb?.id) {
newIconRepositories.push({
id: repositoryIconGroupId,
id: iconRepositoryId,
slug: repositoryIconGroup.slug,
});
}
for (const icon of repositoryIconGroup.icons) {
if (databaseIconGroups.flatMap((group) => group.icons).some((dbIcon) => dbIcon.checksum === icon.checksum)) {
skippedChecksums.push(icon.checksum);
if (
databaseIconRepositories
.flatMap((repository) => repository.icons)
.some((dbIcon) => dbIcon.checksum === icon.checksum && dbIcon.iconRepositoryId === iconRepositoryId)
) {
skippedChecksums.push(`${iconRepositoryId}.${icon.checksum}`);
continue;
}
@@ -63,34 +69,54 @@ export const iconsUpdaterJob = createCronJob("iconsUpdater", EVERY_WEEK, {
checksum: icon.checksum,
name: icon.fileNameWithExtension,
url: icon.imageUrl,
iconRepositoryId: repositoryIconGroupId,
iconRepositoryId,
});
countInserted++;
}
}
const deadIcons = databaseIconGroups
.flatMap((group) => group.icons)
.filter((icon) => !skippedChecksums.includes(icon.checksum));
const deadIcons = databaseIconRepositories
.flatMap((repository) => repository.icons)
.filter((icon) => !skippedChecksums.includes(`${icon.iconRepositoryId}.${icon.checksum}`));
await db.transaction(async (transaction) => {
const deadIconRepositories = databaseIconRepositories.filter(
(iconRepository) => !repositoryIconGroups.some((group) => group.slug === iconRepository.slug),
);
db.transaction((transaction) => {
if (newIconRepositories.length >= 1) {
await transaction.insert(iconRepositories).values(newIconRepositories);
transaction.insert(iconRepositories).values(newIconRepositories).run();
}
if (newIcons.length >= 1) {
// We only insert 5000 icons at a time to avoid SQLite limitations
for (const chunck of splitToNChunks(newIcons, Math.ceil(newIcons.length / 5000))) {
await transaction.insert(icons).values(chunck);
transaction.insert(icons).values(chunck).run();
}
}
if (deadIcons.length >= 1) {
await transaction.delete(icons).where(
inArray(
icons.checksum,
deadIcons.map((icon) => icon.checksum),
),
);
transaction
.delete(icons)
.where(
inArray(
// Combine iconRepositoryId and checksum to allow same icons on different repositories
sql`concat(${icons.iconRepositoryId}, '.', ${icons.checksum})`,
deadIcons.map((icon) => `${icon.iconRepositoryId}.${icon.checksum}`),
),
)
.run();
}
if (deadIconRepositories.length >= 1) {
transaction
.delete(iconRepositories)
.where(
inArray(
iconRepositories.id,
deadIconRepositories.map((iconRepository) => iconRepository.id),
),
)
.run();
}
countDeleted += deadIcons.length;

View File

@@ -0,0 +1,2 @@
ALTER TABLE `user` ADD `default_search_engine_id` varchar(64);--> statement-breakpoint
ALTER TABLE `user` ADD CONSTRAINT `user_default_search_engine_id_search_engine_id_fk` FOREIGN KEY (`default_search_engine_id`) REFERENCES `search_engine`(`id`) ON DELETE set null ON UPDATE no action;

File diff suppressed because it is too large Load Diff

View File

@@ -134,6 +134,13 @@
"when": 1735593853768,
"tag": "0018_mighty_shaman",
"breakpoints": true
},
{
"idx": 19,
"version": "5",
"when": 1735651231818,
"tag": "0019_crazy_marvel_zombies",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1 @@
ALTER TABLE `user` ADD `default_search_engine_id` text REFERENCES search_engine(id);

File diff suppressed because it is too large Load Diff

View File

@@ -134,6 +134,13 @@
"when": 1735593831501,
"tag": "0018_cheerful_tattoo",
"breakpoints": true
},
{
"idx": 19,
"version": "6",
"when": 1735651175378,
"tag": "0019_steady_darkhawk",
"breakpoints": true
}
]
}

View File

@@ -45,7 +45,7 @@
"@paralleldrive/cuid2": "^2.2.2",
"@t3-oss/env-nextjs": "^0.11.1",
"@testcontainers/mysql": "^10.16.0",
"better-sqlite3": "^11.7.0",
"better-sqlite3": "^11.7.2",
"dotenv": "^16.4.7",
"drizzle-kit": "^0.30.1",
"drizzle-orm": "^0.38.3",
@@ -61,6 +61,6 @@
"eslint": "^9.17.0",
"prettier": "^3.4.2",
"tsx": "4.19.2",
"typescript": "^5.7.2"
"typescript": "^5.7.3"
}
}

View File

@@ -62,6 +62,9 @@ export const users = mysqlTable("user", {
homeBoardId: varchar({ length: 64 }).references((): AnyMySqlColumn => boards.id, {
onDelete: "set null",
}),
defaultSearchEngineId: varchar({ length: 64 }).references(() => searchEngines.id, {
onDelete: "set null",
}),
colorScheme: varchar({ length: 5 }).$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: tinyint().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: boolean().default(false).notNull(),
@@ -409,13 +412,17 @@ export const accountRelations = relations(accounts, ({ one }) => ({
}),
}));
export const userRelations = relations(users, ({ many }) => ({
export const userRelations = relations(users, ({ one, many }) => ({
accounts: many(accounts),
boards: many(boards),
boardPermissions: many(boardUserPermissions),
groups: many(groupMembers),
ownedGroups: many(groups),
invites: many(invites),
defaultSearchEngine: one(searchEngines, {
fields: [users.defaultSearchEngineId],
references: [searchEngines.id],
}),
}));
export const mediaRelations = relations(medias, ({ one }) => ({
@@ -573,9 +580,10 @@ export const integrationItemRelations = relations(integrationItems, ({ one }) =>
}),
}));
export const searchEngineRelations = relations(searchEngines, ({ one }) => ({
export const searchEngineRelations = relations(searchEngines, ({ one, many }) => ({
integration: one(integrations, {
fields: [searchEngines.integrationId],
references: [integrations.id],
}),
usersWithDefault: many(users),
}));

View File

@@ -45,6 +45,9 @@ export const users = sqliteTable("user", {
homeBoardId: text().references((): AnySQLiteColumn => boards.id, {
onDelete: "set null",
}),
defaultSearchEngineId: text().references(() => searchEngines.id, {
onDelete: "set null",
}),
colorScheme: text().$type<ColorScheme>().default("dark").notNull(),
firstDayOfWeek: int().$type<DayOfWeek>().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: int({ mode: "boolean" }).default(false).notNull(),
@@ -395,7 +398,7 @@ export const accountRelations = relations(accounts, ({ one }) => ({
}),
}));
export const userRelations = relations(users, ({ many }) => ({
export const userRelations = relations(users, ({ one, many }) => ({
accounts: many(accounts),
boards: many(boards),
boardPermissions: many(boardUserPermissions),
@@ -403,6 +406,10 @@ export const userRelations = relations(users, ({ many }) => ({
ownedGroups: many(groups),
invites: many(invites),
medias: many(medias),
defaultSearchEngine: one(searchEngines, {
fields: [users.defaultSearchEngineId],
references: [searchEngines.id],
}),
}));
export const mediaRelations = relations(medias, ({ one }) => ({
@@ -560,9 +567,10 @@ export const integrationItemRelations = relations(integrationItems, ({ one }) =>
}),
}));
export const searchEngineRelations = relations(searchEngines, ({ one }) => ({
export const searchEngineRelations = relations(searchEngines, ({ one, many }) => ({
integration: one(integrations, {
fields: [searchEngines.integrationId],
references: [integrations.id],
}),
usersWithDefault: many(users),
}));

View File

@@ -30,6 +30,6 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"typescript": "^5.7.2"
"typescript": "^5.7.3"
}
}

View File

@@ -20,122 +20,122 @@ export const integrationDefs = {
sabNzbd: {
name: "SABnzbd",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sabnzbd.png",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/sabnzbd.png",
category: ["downloadClient", "usenet"],
},
nzbGet: {
name: "NZBGet",
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/nzbget.png",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/nzbget.png",
category: ["downloadClient", "usenet"],
},
deluge: {
name: "Deluge",
secretKinds: [["password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/deluge.png",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/deluge.png",
category: ["downloadClient", "torrent"],
},
transmission: {
name: "Transmission",
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/transmission.png",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/transmission.png",
category: ["downloadClient", "torrent"],
},
qBittorrent: {
name: "qBittorrent",
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/qbittorrent.png",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/qbittorrent.png",
category: ["downloadClient", "torrent"],
},
sonarr: {
name: "Sonarr",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sonarr.png",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/sonarr.png",
category: ["calendar"],
},
radarr: {
name: "Radarr",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/radarr.png",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/radarr.png",
category: ["calendar"],
},
lidarr: {
name: "Lidarr",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/lidarr.png",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/lidarr.png",
category: ["calendar"],
},
readarr: {
name: "Readarr",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/readarr.png",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/readarr.png",
category: ["calendar"],
},
prowlarr: {
name: "Prowlarr",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/prowlarr.png",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/prowlarr.png",
category: ["indexerManager"],
},
jellyfin: {
name: "Jellyfin",
secretKinds: [["username", "password"], ["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyfin.png",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/jellyfin.png",
category: ["mediaService"],
},
plex: {
name: "Plex",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/plex.png",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/plex.png",
category: ["mediaService"],
},
jellyseerr: {
name: "Jellyseerr",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyseerr.png",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/jellyseerr.png",
category: ["mediaSearch", "mediaRequest", "search"],
},
overseerr: {
name: "Overseerr",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/overseerr.png",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/overseerr.png",
category: ["mediaSearch", "mediaRequest", "search"],
},
piHole: {
name: "Pi-hole",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/pi-hole.png",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/pi-hole.png",
category: ["dnsHole"],
},
adGuardHome: {
name: "AdGuard Home",
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/adguard-home.png",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/adguard-home.png",
category: ["dnsHole"],
},
homeAssistant: {
name: "Home Assistant",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/home-assistant.png",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/home-assistant.png",
category: ["smartHomeServer"],
},
openmediavault: {
name: "OpenMediaVault",
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/openmediavault.png",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/openmediavault.png",
category: ["healthMonitoring"],
},
dashDot: {
name: "Dash.",
secretKinds: [[]],
category: ["healthMonitoring"],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/dashdot.png",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/dashdot.png",
},
tdarr: {
name: "Tdarr",
secretKinds: [[]],
category: ["mediaTranscoding"],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/tdarr.png",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/tdarr.png",
},
} as const satisfies Record<string, integrationDefinition>;

View File

@@ -26,13 +26,13 @@
"@homarr/common": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/form": "^7.15.2"
"@mantine/form": "^7.15.3"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"typescript": "^5.7.2"
"typescript": "^5.7.3"
}
}

View File

@@ -32,6 +32,6 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"typescript": "^5.7.2"
"typescript": "^5.7.3"
}
}

View File

@@ -2,8 +2,8 @@ import type { Database } from "@homarr/db";
import { like } from "@homarr/db";
import { icons } from "@homarr/db/schema";
export const getIconForNameAsync = async (db: Database, name: string) => {
return await db.query.icons.findFirst({
export const getIconForName = (db: Database, name: string) => {
return db.query.icons.findFirst({
where: like(icons.name, `%${name}%`),
});
};

View File

@@ -5,12 +5,12 @@ import type { RepositoryIconGroup } from "./types";
const repositories = [
new GitHubIconRepository(
"Walkxcode",
"walkxcode/dashboard-icons",
"Dashboard Icons",
"homarr-labs/dashboard-icons",
undefined,
new URL("https://github.com/walkxcode/dashboard-icons"),
new URL("https://api.github.com/repos/walkxcode/dashboard-icons/git/trees/main?recursive=true"),
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/{0}",
new URL("https://github.com/homarr-labs/dashboard-icons"),
new URL("https://api.github.com/repos/homarr-labs/dashboard-icons/git/trees/main?recursive=true"),
"https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/{0}",
),
new GitHubIconRepository(
"selfh.st",

View File

@@ -43,6 +43,6 @@
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/xml2js": "^0.4.14",
"eslint": "^9.17.0",
"typescript": "^5.7.2"
"typescript": "^5.7.3"
}
}

View File

@@ -35,6 +35,6 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"typescript": "^5.7.2"
"typescript": "^5.7.3"
}
}

View File

@@ -32,17 +32,18 @@
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^7.15.2",
"@tabler/icons-react": "^3.26.0",
"@mantine/core": "^7.15.3",
"@tabler/icons-react": "^3.28.1",
"dayjs": "^1.11.13",
"next": "^14.2.22",
"react": "^19.0.0"
"next": "15.1.4",
"react": "19.0.0",
"react-dom": "19.0.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"typescript": "^5.7.2"
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,96 @@
import { Button, Group, Stack, Text, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { MaybePromise } from "@homarr/common/types";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
import { createModal } from "../../../modals/src/creator";
import { useBoardNameStatus } from "./add-board-modal";
interface InnerProps {
board: {
id: string;
name: string;
};
onSuccess: () => MaybePromise<void>;
}
export const DuplicateBoardModal = createModal<InnerProps>(({ actions, innerProps }) => {
const t = useI18n();
const form = useZodForm(validation.board.duplicate.omit({ id: true }), {
mode: "controlled",
initialValues: {
name: innerProps.board.name,
},
});
const boardNameStatus = useBoardNameStatus(form.values.name);
const { mutateAsync, isPending } = clientApi.board.duplicateBoard.useMutation();
return (
<form
onSubmit={form.onSubmit(async (values) => {
// Prevent submit before name availability check
if (!boardNameStatus.canSubmit) return;
await mutateAsync(
{
...values,
id: innerProps.board.id,
},
{
async onSuccess() {
actions.closeModal();
showSuccessNotification({
title: t("board.action.duplicate.notification.success.title"),
message: t("board.action.duplicate.notification.success.message"),
});
await innerProps.onSuccess();
},
onError() {
showErrorNotification({
title: t("board.action.duplicate.notification.error.title"),
message: t("board.action.duplicate.notification.error.message"),
});
},
},
);
})}
>
<Stack>
<Text size="sm" c="gray.6">
{t("board.action.duplicate.message", { name: innerProps.board.name })}
</Text>
<TextInput
label={t("board.field.name.label")}
data-autofocus
{...form.getInputProps("name")}
description={
boardNameStatus.description ? (
<Group c={boardNameStatus.description.color} gap="xs" align="center">
{boardNameStatus.description.icon ? <boardNameStatus.description.icon size={16} /> : null}
<span>{boardNameStatus.description.label}</span>
</Group>
) : null
}
withAsterisk
/>
<Group justify="end">
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
{t("common.action.cancel")}
</Button>
<Button type="submit" loading={isPending}>
{t("common.action.create")}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle(t) {
return t("board.action.duplicate.title");
},
});

View File

@@ -1,2 +1,3 @@
export { AddBoardModal } from "./add-board-modal";
export { ImportBoardModal } from "./import-board-modal";
export { DuplicateBoardModal } from "./duplicate-board-modal";

View File

@@ -0,0 +1,91 @@
import { Button, Group, Image, List, LoadingOverlay, Stack, Text, TextInput } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form";
import { createModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { z } from "@homarr/validation";
interface AddDockerAppToHomarrProps {
selectedContainers: RouterOutputs["docker"]["getContainers"]["containers"];
}
export const AddDockerAppToHomarrModal = createModal<AddDockerAppToHomarrProps>(({ actions, innerProps }) => {
const t = useI18n();
const form = useZodForm(
z.object({
containerUrls: z.array(z.string().url().nullable()),
}),
{
initialValues: {
containerUrls: innerProps.selectedContainers.map((container) => {
if (container.ports[0]) {
return `http://${container.ports[0].IP}:${container.ports[0].PublicPort}`;
}
return null;
}),
},
},
);
const { mutate, isPending } = clientApi.app.createMany.useMutation({
onSuccess() {
actions.closeModal();
showSuccessNotification({
title: t("docker.action.addToHomarr.notification.success.title"),
message: t("docker.action.addToHomarr.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("docker.action.addToHomarr.notification.error.title"),
message: t("docker.action.addToHomarr.notification.error.message"),
});
},
});
const handleSubmit = () => {
mutate(
innerProps.selectedContainers.map((container, index) => ({
name: container.name,
iconUrl: container.iconUrl,
description: null,
href: form.values.containerUrls[index] ?? null,
})),
);
};
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<LoadingOverlay visible={isPending} zIndex={1000} overlayProps={{ radius: "sm", blur: 2 }} />
<Stack>
<List>
{innerProps.selectedContainers.map((container, index) => (
<List.Item
styles={{ itemWrapper: { width: "100%" }, itemLabel: { flex: 1 } }}
icon={<Image src={container.iconUrl} alt="container image" w={30} h={30} />}
key={container.id}
>
<Group justify="space-between">
<Text>{container.name}</Text>
<TextInput {...form.getInputProps(`containerUrls.${index}`)} />
</Group>
</List.Item>
))}
</List>
<Group justify="end">
<Button onClick={actions.closeModal} variant="light">
{t("common.action.cancel")}
</Button>
<Button disabled={!form.isValid()} type="submit">
{t("common.action.add")}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle(t) {
return t("docker.action.addToHomarr.modal.title");
},
});

View File

@@ -0,0 +1 @@
export { AddDockerAppToHomarrModal as AddDockerAppToHomarr } from "./add-docker-app-to-homarr";

View File

@@ -2,3 +2,4 @@ export * from "./boards";
export * from "./invites";
export * from "./groups";
export * from "./search-engines";
export * from "./docker";

View File

@@ -24,15 +24,15 @@
"dependencies": {
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@mantine/core": "^7.15.2",
"@mantine/hooks": "^7.15.2",
"react": "^19.0.0"
"@mantine/core": "^7.15.3",
"@mantine/hooks": "^7.15.3",
"react": "19.0.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"typescript": "^5.7.2"
"typescript": "^5.7.3"
}
}

View File

@@ -24,14 +24,14 @@
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/ui": "workspace:^0.1.0",
"@mantine/notifications": "^7.15.2",
"@tabler/icons-react": "^3.26.0"
"@mantine/notifications": "^7.15.3",
"@tabler/icons-react": "^3.28.1"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"typescript": "^5.7.2"
"typescript": "^5.7.3"
}
}

View File

@@ -37,11 +37,12 @@
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^7.15.2",
"@mantine/hooks": "^7.15.2",
"@mantine/core": "^7.15.3",
"@mantine/hooks": "^7.15.3",
"adm-zip": "0.5.16",
"next": "^14.2.22",
"react": "^19.0.0",
"next": "15.1.4",
"react": "19.0.0",
"react-dom": "19.0.0",
"superjson": "2.2.2",
"zod": "^3.24.1",
"zod-form-data": "^2.0.5"
@@ -52,6 +53,6 @@
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/adm-zip": "0.5.7",
"eslint": "^9.17.0",
"typescript": "^5.7.2"
"typescript": "^5.7.3"
}
}

View File

@@ -30,6 +30,6 @@
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.17.0",
"typescript": "^5.7.2"
"typescript": "^5.7.3"
}
}

Some files were not shown because too many files have changed in this diff Show More