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

+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>
);
};