feat: announce Homarr v2 beta

This commit is contained in:
ajnart committed 2026-09-06 19:14:35 +02:00
1 parent da32acb4f0
commit 040d154744
10 files changed
+618 -32

No files matched your search

@@ -0,0 +1,61 @@
name: Homarr v2 beta feedback
description: Tell us what works, what breaks, or what could be clearer in the Homarr v2 beta
title: "feedback(v2): "
labels: ["needs triage"]
body:
- type: markdown
attributes:
value: |
Thanks for testing Homarr v2. It will become the default when it is ready, and every report helps us get there.
- type: dropdown
id: feedback-type
attributes:
label: Feedback type
options:
- Bug
- Upgrade or migration
- Usability or design
- Performance
- Other
validations:
required: true
- type: input
id: version
attributes:
label: Homarr v2 version or image tag
description: You can find the version in the About page or provide the Docker image tag you tested.
placeholder: ghcr.io/homarr-labs/homarr-test:v2
validations:
required: true
- type: textarea
id: feedback
attributes:
label: What did you find?
description: Describe what happened, what you expected, or what you think could be improved.
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to reproduce
description: For bugs, list the shortest steps that consistently reproduce the problem.
- type: textarea
id: additional-information
attributes:
label: Additional information
description: Add screenshots, logs, browser details, or anything else that may help. Remove secrets and personal information first.
- type: checkboxes
id: checks
attributes:
label: Before submitting
options:
- label: I searched the existing issues for the same feedback.
required: true
- label: I removed passwords, API keys, tokens, and other private information.
required: true
+6
View File
@@ -11,6 +11,12 @@ tags:
import Link from '@docusaurus/Link';
import { IconArrowRight } from '@tabler/icons-react';
:::info Homarr v2 beta is available
There is no release date yet: Homarr v2 will become the default when it is ready. Read [what is new](https://v2.preview.homarr.dev/blog), try the [hosted preview](https://app-v2.preview.homarr.dev), [join us on Discord](https://discord.com/invite/aCsmEV5RgA), and [share your feedback](https://github.com/homarr-labs/homarr/issues/new?template=v2_beta_feedback.yml). Everyone uses Homarr differently, so every report helps us find what breaks or does not feel right before release.
:::
Before you try to install Homarr, please review if your device meets the requirements.
Installing on an unsupported device may lead to unstable behaviour and no support will be provided.
@@ -1,6 +1,8 @@
import { createHash } from "node:crypto";
import type { JSX, PropsWithChildren } from "react";
import { notFound, redirect } from "next/navigation";
import { AppShellMain } from "@mantine/core";
import { cookies } from "next/headers";
import { TRPCError } from "@trpc/server";
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
@@ -8,10 +10,11 @@ import { auth } from "@homarr/auth/next";
import { BoardProvider } from "@homarr/boards/context";
import { EditModeProvider } from "@homarr/boards/edit-mode";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { v2BetaAnnouncementCookieKey } from "@homarr/definitions";
import { MainHeader } from "~/components/layout/header";
import { MainHeaderContent } from "~/components/layout/header";
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
import { ClientShell } from "~/components/layout/shell";
import { V2BetaDashboardShell } from "~/components/layout/v2-beta-announcement/v2-beta-dashboard-shell";
import { BoardTourGate } from "~/components/onboarding/board-tour-gate";
import { env } from "~/env";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
@@ -42,6 +45,7 @@ export const createBoardLayout = <TParams extends Params>({
}>) => {
const resolvedParams = await params;
const sessionPromise = auth();
const cookiesPromise = cookies();
const initialBoardPromise = getInitialBoard(resolvedParams).then(
(board) => ({ status: "fulfilled", board }) as const,
(error: unknown) => ({ status: "rejected", error }) as const,
@@ -58,11 +62,12 @@ export const createBoardLayout = <TParams extends Params>({
return false;
}
});
const [session, initialBoardResult, colorScheme, shouldRunBoardTour] = await Promise.all([
const [session, initialBoardResult, colorScheme, shouldRunBoardTour, cookieStore] = await Promise.all([
sessionPromise,
initialBoardPromise,
colorSchemePromise,
shouldRunBoardTourPromise,
cookiesPromise,
]);
if (initialBoardResult.status === "rejected") {
const { error } = initialBoardResult;
@@ -83,6 +88,10 @@ export const createBoardLayout = <TParams extends Params>({
throw error;
}
const initialBoard = initialBoardResult.board;
const viewerIdentity = session?.user.id ?? "anonymous";
const viewerHash = createHash("sha256").update(viewerIdentity).digest("hex").slice(0, 16);
const dismissalCookieName = `${v2BetaAnnouncementCookieKey}.${viewerHash}`;
const isAnnouncementDismissed = cookieStore.get(dismissalCookieName)?.value === "dismissed";
return (
<BoardProvider initialBoard={initialBoard}>
@@ -91,14 +100,19 @@ export const createBoardLayout = <TParams extends Params>({
<BoardMantineProvider defaultColorScheme={colorScheme}>
<CustomCss />
<BoardTourGate enabled={shouldRunBoardTour}>
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
actions={headerActions}
hasNavigation={false}
/>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
<V2BetaDashboardShell
dismissalCookieName={dismissalCookieName}
initiallyDismissed={isAnnouncementDismissed}
header={
<MainHeaderContent
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
actions={headerActions}
hasNavigation={false}
/>
}
>
{children}
</V2BetaDashboardShell>
</BoardTourGate>
</BoardMantineProvider>
</EditModeProvider>
+25 -19
View File
@@ -19,25 +19,31 @@ interface Props {
export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
return (
<AppShellHeader maw="100vw" zIndex={201} style={{ overflowX: "hidden" }}>
<Group h="100%" gap="xl" px="md" justify="apart" wrap="nowrap">
<Group h="100%" align="center" style={{ flex: 1 }} wrap="nowrap">
{hasNavigation && <ClientBurger />}
<UnstyledButton component={Link} href="/">
{logo ?? <HomarrLogoWithTitle size="md" />}
</UnstyledButton>
</Group>
<TourTarget id="board-search">
<DesktopSearchInput />
</TourTarget>
<Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap">
{actions}
<MobileSearchButton />
<TourTarget id="board-user-menu">
<UserButton />
</TourTarget>
</Group>
</Group>
<LazySpotlight />
<MainHeaderContent logo={logo} actions={actions} hasNavigation={hasNavigation} />
</AppShellHeader>
);
};
export const MainHeaderContent = ({ logo, actions, hasNavigation = true }: Props) => (
<>
<Group h={60} gap="xl" px="md" justify="apart" wrap="nowrap">
<Group h="100%" align="center" style={{ flex: 1 }} wrap="nowrap">
{hasNavigation && <ClientBurger />}
<UnstyledButton component={Link} href="/">
{logo ?? <HomarrLogoWithTitle size="md" />}
</UnstyledButton>
</Group>
<TourTarget id="board-search">
<DesktopSearchInput />
</TourTarget>
<Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap">
{actions}
<MobileSearchButton />
<TourTarget id="board-user-menu">
<UserButton />
</TourTarget>
</Group>
</Group>
<LazySpotlight />
</>
);
+3 -1
View File
@@ -10,11 +10,13 @@ import { navigationCollapsedAtom } from "./header/burger";
interface ClientShellProps {
hasHeader?: boolean;
hasNavigation?: boolean;
headerHeight?: number;
}
export const ClientShell = ({
hasHeader = true,
hasNavigation = true,
headerHeight = 60,
children,
}: PropsWithChildren<ClientShellProps>) => {
const collapsed = useAtomValue(navigationCollapsedAtom);
@@ -23,7 +25,7 @@ export const ClientShell = ({
return (
<AppShell
{...backgroundProps}
header={hasHeader ? { height: 60 } : undefined}
header={hasHeader ? { height: headerHeight } : undefined}
navbar={
hasNavigation
? {
@@ -0,0 +1,5 @@
export const v2BetaBlogUrl = "https://v2.preview.homarr.dev/blog";
export const v2BetaPreviewUrl = "https://app-v2.preview.homarr.dev";
export const v2BetaFeedbackUrl = "https://github.com/homarr-labs/homarr/issues/new?template=v2_beta_feedback.yml";
export const v2BetaDiscussionUrl = "https://github.com/homarr-labs/homarr/issues/6600";
export const discordInviteUrl = "https://discord.com/invite/aCsmEV5RgA";
@@ -0,0 +1,110 @@
.banner {
position: relative;
isolation: isolate;
height: 52px;
overflow: hidden;
color: var(--mantine-color-white);
background: var(--mantine-color-red-9);
border-bottom: 1px solid rgb(255 255 255 / 18%);
}
.banner::after {
position: absolute;
z-index: -1;
top: -1.75rem;
right: 12%;
color: rgb(255 255 255 / 9%);
font-size: 5.5rem;
font-weight: 900;
line-height: 1;
content: "v2";
transform: rotate(5deg);
}
.copy {
min-width: 0;
}
.subtitle {
opacity: 0.86;
}
.modalHero {
position: relative;
isolation: isolate;
overflow: hidden;
color: var(--mantine-color-white);
background: var(--mantine-color-red-9);
}
.modalHero::after {
position: absolute;
z-index: -1;
top: -3.5rem;
right: 1.5rem;
color: rgb(255 255 255 / 8%);
font-size: 12rem;
font-weight: 900;
line-height: 1;
content: "v2";
transform: rotate(5deg);
}
.featureCard {
height: 100%;
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
}
.qolCard {
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
}
.diffViewer {
overflow: hidden;
}
.diffHeader {
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-7));
border-bottom: 1px solid var(--mantine-color-default-border);
}
.diffCode {
margin: 0;
padding-block: var(--mantine-spacing-sm);
overflow-x: auto;
color: light-dark(var(--mantine-color-dark-8), var(--mantine-color-gray-2));
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-9));
font-family: var(--mantine-font-family-monospace);
font-size: var(--mantine-font-size-sm);
line-height: 1.6;
}
.diffLine_context,
.diffLine_added,
.diffLine_removed {
display: block;
width: max-content;
min-width: 100%;
padding-inline: var(--mantine-spacing-md);
white-space: pre;
}
.diffLine_added {
color: light-dark(var(--mantine-color-green-9), var(--mantine-color-green-3));
background: light-dark(var(--mantine-color-green-0), rgb(47 158 68 / 15%));
}
.diffLine_removed {
color: light-dark(var(--mantine-color-red-9), var(--mantine-color-red-3));
background: light-dark(var(--mantine-color-red-0), rgb(250 82 82 / 15%));
}
@media (max-width: $mantine-breakpoint-xs) {
.banner::after {
right: 35%;
}
.modalHero::after {
right: -2rem;
}
}
@@ -0,0 +1,342 @@
"use client";
import type { ReactNode } from "react";
import {
ActionIcon,
Alert,
Badge,
Box,
Button,
CopyButton,
Group,
Paper,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import {
IconArrowsMove,
IconBrandCss3,
IconBrandDiscord,
IconBrandGithub,
IconCheck,
IconCopy,
IconExternalLink,
IconHeartHandshake,
IconRobot,
IconRocket,
IconSparkles,
IconTerminal2,
IconWand,
IconX,
} from "@tabler/icons-react";
import { setClientCookie } from "@homarr/common";
import { createModal, useModalAction } from "@homarr/modals";
import { discordInviteUrl, v2BetaBlogUrl, v2BetaDiscussionUrl, v2BetaFeedbackUrl, v2BetaPreviewUrl } from "./constants";
import classes from "./v2-beta-announcement.module.css";
const diffLineClassNames = {
added: classes.diffLine_added,
context: classes.diffLine_context,
removed: classes.diffLine_removed,
} as const;
const composeDiffLines = [
{ kind: "context", value: " services:" },
{ kind: "context", value: " homarr:" },
{ kind: "added", value: "+ image: ghcr.io/homarr-labs/homarr-test:v2" },
{ kind: "removed", value: "- image: ghcr.io/homarr-labs/homarr:latest" },
{ kind: "context", value: " ports:" },
{ kind: "context", value: ' - "7575:7575"' },
{ kind: "context", value: " volumes:" },
{ kind: "context", value: " - homarr-v2:/appdata" },
{ kind: "context", value: " environment:" },
{ kind: "context", value: " SECRET_ENCRYPTION_KEY: <SECRET_KEY>" },
{ kind: "context", value: " WORKSHOP_API_URL: https://v2.preview.homarr.dev/ # Only during the beta" },
{ kind: "context", value: "" },
{ kind: "context", value: " volumes:" },
{ kind: "context", value: " homarr-v2:" },
] as const;
const v2ComposeFile = `services:
homarr:
image: ghcr.io/homarr-labs/homarr-test:v2
ports:
- "7575:7575"
volumes:
- homarr-v2:/appdata
environment:
SECRET_ENCRYPTION_KEY: <SECRET_KEY>
WORKSHOP_API_URL: https://v2.preview.homarr.dev/ # Only during the beta
volumes:
homarr-v2:
`;
interface V2BetaAnnouncementProps {
dismissalCookieName: string;
onDismiss: () => void;
}
export const V2BetaAnnouncement = ({ dismissalCookieName, onDismiss }: V2BetaAnnouncementProps) => {
const { openModal } = useModalAction(V2BetaAnnouncementModal);
const dismiss = () => {
setClientCookie(dismissalCookieName, "dismissed", {
maxAge: 60 * 60 * 24 * 365,
path: "/",
sameSite: "lax",
secure: window.location.protocol === "https:",
});
onDismiss();
};
return (
<Box component="aside" className={classes.banner} aria-label="Homarr v2 beta announcement">
<Group h="100%" justify="space-between" gap="sm" wrap="nowrap" px="md">
<Group gap="sm" wrap="nowrap" className={classes.copy}>
<ThemeIcon variant="white" color="red" radius="xl" size="md">
<IconSparkles size={18} />
</ThemeIcon>
<Box className={classes.copy}>
<Text fw={750} size="sm" truncate>
Homarr v2 beta is here
</Text>
<Text className={classes.subtitle} visibleFrom="sm" size="xs" truncate>
Test it with us and help make v2 ready for everyone.
</Text>
</Box>
</Group>
<Group gap={6} wrap="nowrap">
<Button variant="white" color="red" size="compact-sm" onClick={() => openModal(undefined)}>
Explore v2
</Button>
<ActionIcon variant="subtle" color="white" onClick={dismiss} aria-label="Dismiss the v2 beta announcement">
<IconX size={18} />
</ActionIcon>
</Group>
</Group>
</Box>
);
};
const V2BetaAnnouncementModal = createModal<void>(() => {
const { openModal: openInstallModal } = useModalAction(V2BetaInstallModal);
return (
<Stack gap="lg">
<Paper className={classes.modalHero} p={{ base: "lg", sm: "xl" }} radius="md">
<Stack gap="sm" maw={760}>
<Badge variant="white" color="red" size="lg" w="fit-content">
Public beta
</Badge>
<Title order={2}>Help us get Homarr v2 ready</Title>
<Text>
There is no release date yet. Homarr v2 will become the default when it is ready—and we need you to test it
and share feedback before we can be confident enough to make that switch.
</Text>
</Stack>
</Paper>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<FeatureCard icon={<IconBrandCss3 size={21} />} title="Homarr Workshop">
Share and discover Custom Widgets and CSS made by the Homarr community.
</FeatureCard>
<FeatureCard icon={<IconArrowsMove size={21} />} title="Complete drag-and-drop rewrite">
Move, resize, multi-select, and arrange your dashboard with a board editor rebuilt from the ground up.
</FeatureCard>
<FeatureCard icon={<IconWand size={21} />} title="Custom Widgets v2">
We think you can make almost any widget with live previews, API requests, options, actions, and validation.
</FeatureCard>
<FeatureCard icon={<IconRobot size={21} />} title="Homarr Assistant">
Ask questions, find what you need, and manage Homarr through permission-aware tools.
</FeatureCard>
</SimpleGrid>
<Paper withBorder p="md" radius="md" className={classes.qolCard}>
<Stack gap="sm">
<Text fw={750}>And so much more</Text>
<Text c="dimmed" size="sm">
Hundreds of quality-of-life improvements, including:
</Text>
<Group gap="xs">
<Badge variant="light">Brand-new onboarding</Badge>
<Badge variant="light">Visual board switcher</Badge>
<Badge variant="light">Keyboard shortcuts</Badge>
<Badge variant="light">Instance branding</Badge>
<Badge variant="light">Custom header buttons</Badge>
<Badge variant="light">Custom header order</Badge>
</Group>
</Stack>
</Paper>
<Alert variant="light" color="red" icon={<IconHeartHandshake size={22} />} title="Your testing is essential">
Everyone uses Homarr in a different way. We’re counting on you to tell us anything that breaks or doesn’t feel
right with this new version. Every report matters.
</Alert>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<Button
component="a"
href={v2BetaPreviewUrl}
target="_blank"
rel="noopener noreferrer"
size="md"
leftSection={<IconRocket size={18} />}
>
Try the hosted preview
</Button>
<Button
variant="light"
size="md"
leftSection={<IconTerminal2 size={18} />}
onClick={() => openInstallModal(undefined)}
>
Test v2 on your server
</Button>
<Button
component="a"
href={v2BetaBlogUrl}
target="_blank"
rel="noopener noreferrer"
variant="default"
size="md"
rightSection={<IconExternalLink size={16} />}
>
See everything new
</Button>
<Button
component="a"
href={discordInviteUrl}
target="_blank"
rel="noopener noreferrer"
variant="default"
size="md"
leftSection={<IconBrandDiscord size={18} />}
>
Join us on Discord
</Button>
</SimpleGrid>
<Button
component="a"
href={v2BetaFeedbackUrl}
target="_blank"
rel="noopener noreferrer"
variant="light"
color="red"
size="md"
leftSection={<IconBrandGithub size={18} />}
>
Report anything that breaks or feels wrong
</Button>
<Group justify="space-between" align="center">
<Text c="dimmed" size="xs" maw={720}>
Please use a separate volume for the beta and keep a backup of your current Homarr data.
</Text>
<Button
component="a"
href={v2BetaDiscussionUrl}
target="_blank"
rel="noopener noreferrer"
variant="subtle"
size="compact-sm"
rightSection={<IconExternalLink size={15} />}
>
Follow v2 development
</Button>
</Group>
</Stack>
);
}).withOptions({
defaultTitle: "Homarr v2 beta",
size: 1000,
centered: true,
});
const V2BetaInstallModal = createModal<void>(() => (
<Stack gap="lg">
<Alert color="yellow" title="Keep your current Homarr data safe">
Run the beta with the separate <code>homarr-v2</code> volume shown below. Keep a backup, and do not point this
prerelease image at the only copy of your production data.
</Alert>
<Stack gap="xs">
<Title order={3}>Update your Docker Compose file</Title>
<Text c="dimmed" size="sm">
Replace the current image and add the beta-only Workshop URL. The complete example below can be copied as a
starting point.
</Text>
</Stack>
<Paper withBorder radius="md" className={classes.diffViewer}>
<Group justify="space-between" px="md" py="sm" className={classes.diffHeader}>
<Group gap="xs">
<IconTerminal2 size={17} />
<Text fw={700} size="sm">
docker-compose.yml
</Text>
</Group>
<CopyButton value={v2ComposeFile} timeout={2000}>
{({ copied, copy }) => (
<Button
variant="subtle"
color="gray"
size="compact-sm"
leftSection={copied ? <IconCheck size={15} /> : <IconCopy size={15} />}
onClick={copy}
>
{copied ? "Copied" : "Copy file"}
</Button>
)}
</CopyButton>
</Group>
<Box component="pre" className={classes.diffCode} aria-label="Docker Compose changes for Homarr v2 beta">
{composeDiffLines.map((line, index) => (
<Box component="span" className={diffLineClassNames[line.kind]} key={`${line.kind}-${index}`}>
{line.value || " "}
</Box>
))}
</Box>
</Paper>
<Text c="dimmed" size="xs">
Replace <code>&lt;SECRET_KEY&gt;</code> with your existing encryption key. The Workshop URL is only required while
the beta uses the preview Workshop.
</Text>
</Stack>
)).withOptions({
defaultTitle: "Test Homarr v2 on your server",
size: 900,
centered: true,
});
interface FeatureCardProps {
icon: ReactNode;
title: string;
children: ReactNode;
}
const FeatureCard = ({ icon, title, children }: FeatureCardProps) => (
<Paper className={classes.featureCard} withBorder p="md" radius="md">
<Group gap="sm" align="flex-start" wrap="nowrap">
<ThemeIcon variant="light" color="red" radius="md" size="lg">
{icon}
</ThemeIcon>
<Box>
<Text fw={750} size="sm">
{title}
</Text>
<Text c="dimmed" size="sm">
{children}
</Text>
</Box>
</Group>
</Paper>
);
@@ -0,0 +1,39 @@
"use client";
import type { PropsWithChildren, ReactNode } from "react";
import { useState } from "react";
import { AppShellHeader, AppShellMain } from "@mantine/core";
import { ClientShell } from "../shell";
import { V2BetaAnnouncement } from "./v2-beta-announcement";
interface V2BetaDashboardShellProps {
dismissalCookieName: string;
initiallyDismissed: boolean;
header: ReactNode;
}
export const V2BetaDashboardShell = ({
dismissalCookieName,
initiallyDismissed,
header,
children,
}: PropsWithChildren<V2BetaDashboardShellProps>) => {
const [isAnnouncementVisible, setIsAnnouncementVisible] = useState(!initiallyDismissed);
const headerHeight = isAnnouncementVisible ? 112 : 60;
return (
<ClientShell hasNavigation={false} headerHeight={headerHeight}>
<AppShellHeader maw="100vw" zIndex={201} style={{ overflowX: "hidden" }}>
{isAnnouncementVisible && (
<V2BetaAnnouncement
dismissalCookieName={dismissalCookieName}
onDismiss={() => setIsAnnouncementVisible(false)}
/>
)}
{header}
</AppShellHeader>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
);
};
+1
View File
@@ -1,2 +1,3 @@
export const colorSchemeCookieKey = "homarr.color-scheme";
export const localeCookieKey = "homarr.locale";
export const v2BetaAnnouncementCookieKey = "homarr.v2-beta-announcement";