docs: streamline release v2 documentation (#6760)

This commit is contained in:
Thomas authored and GitHub committed 2026-09-03 17:03:10 +02:00
1 parent ac62e5bfd6
commit b3aa6155cc
163 files changed
+2908 -4208

No files matched your search

@@ -1,41 +0,0 @@
.link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.7rem;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 0.5rem;
background: var(--ifm-background-surface-color);
color: var(--ifm-font-color-base);
font-size: 0.875rem;
font-weight: 600;
line-height: 1.25;
text-decoration: none;
transition:
background-color 150ms ease,
border-color 150ms ease;
}
.link:hover {
border-color: var(--ifm-color-emphasis-500);
background: var(--ifm-color-emphasis-100);
color: var(--ifm-font-color-base);
text-decoration: none;
}
.link:focus-visible {
outline: 2px solid var(--ifm-color-primary);
outline-offset: 2px;
}
.logo {
width: 1.1rem;
height: 1.1rem;
flex: 0 0 auto;
}
@media (prefers-reduced-motion: reduce) {
.link {
transition: none;
}
}
@@ -1,10 +0,0 @@
import Translate from "@docusaurus/Translate";
import styles from "./open-collective-link.module.css";
export const OpenCollectiveLink = () => (
<a className={styles.link} href="https://opencollective.com/homarr">
<img className={styles.logo} src="/img/opencollective.svg" alt="" aria-hidden />
<Translate id="workshop.provider.donate">Donate to Homarr on OpenCollective</Translate>
</a>
);
@@ -1,8 +1,8 @@
export const AddingIntegration = () => {
return (
<p>
You can find how to add the integration on the <a href="/docs/management/integrations">Integrations Management</a>{" "}
documentation page.
Create the connection under <strong>Management → Integrations</strong>. See{" "}
<a href="/docs/management/integrations">Managing integrations</a>.
</p>
);
};
@@ -0,0 +1,400 @@
.diagram {
--diagram-ink: var(--ifm-font-color-base);
--diagram-muted: var(--ifm-color-emphasis-700);
--diagram-panel: var(--ifm-background-surface-color);
--diagram-surface: var(--ifm-background-color);
--diagram-line: var(--ifm-color-emphasis-300);
display: grid;
gap: 0.9rem;
margin: 1.25rem 0 1.75rem;
padding: 1rem;
border: 1px solid var(--diagram-line);
border-radius: 0.75rem;
color: var(--diagram-ink);
background: var(--diagram-panel);
}
.routeBlock {
display: grid;
gap: 0.55rem;
}
.routeBlock + .routeBlock {
padding-top: 0.85rem;
border-top: 1px solid var(--diagram-line);
}
.routeHeading {
display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.routeHeading > span {
display: grid;
place-items: center;
color: var(--ifm-color-primary);
}
.routeHeading strong {
font-size: 0.74rem;
}
.routeHeading small {
color: var(--diagram-muted);
font-family: var(--font-mono);
font-size: 0.58rem;
}
.threeStepLane,
.fourStepLane {
display: grid;
align-items: stretch;
gap: 0.35rem;
}
.threeStepLane {
grid-template-columns: minmax(0, 1fr) 3.25rem minmax(0, 1fr) 3.25rem minmax(0, 1fr);
}
.fourStepLane {
grid-template-columns: minmax(0, 1fr) 3.1rem minmax(0, 1fr) 3.1rem minmax(0, 1fr) 3.1rem minmax(0, 1fr);
}
.node {
display: grid;
align-content: start;
gap: 0.65rem;
min-width: 0;
min-height: 6.25rem;
padding: 0.7rem;
border: 1px solid var(--diagram-line);
border-radius: 0.55rem;
color: var(--diagram-ink);
background: var(--diagram-surface);
text-decoration: none;
}
.nodeLink:hover {
border-color: var(--ifm-color-primary);
color: var(--diagram-ink);
text-decoration: none;
}
.nodeHeader {
display: flex;
align-items: flex-start;
gap: 0.5rem;
min-width: 0;
}
.nodeIcon,
.largeIcon {
display: grid;
flex: none;
place-items: center;
border: 1px solid var(--diagram-line);
color: var(--ifm-color-primary);
background: var(--diagram-panel);
}
.nodeIcon {
width: 1.75rem;
height: 1.75rem;
border-radius: 0.45rem;
}
.nodeIcon img {
width: 1.15rem;
height: 1.15rem;
object-fit: contain;
}
.nodeCopy {
display: grid;
min-width: 0;
}
.nodeCopy strong {
font-size: 0.72rem;
line-height: 1.25;
}
.nodeCopy small {
color: var(--diagram-muted);
font-size: 0.59rem;
line-height: 1.35;
}
.nodeBody {
display: grid;
align-content: start;
min-width: 0;
}
.nodeBody code {
overflow: hidden;
display: block;
padding: 0.25rem 0.35rem;
border: 1px solid var(--diagram-line);
border-radius: 0.3rem;
color: var(--diagram-muted);
background: var(--diagram-panel);
font-size: 0.53rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.pillList {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.pillList span,
.pillList a {
padding: 0.15rem 0.3rem;
border: 1px solid var(--diagram-line);
border-radius: 999px;
color: var(--diagram-muted);
background: var(--diagram-panel);
font-size: 0.5rem;
line-height: 1.25;
text-decoration: none;
}
.pillList a:hover {
border-color: var(--ifm-color-primary);
color: var(--ifm-color-primary);
text-decoration: none;
}
.localDetails,
.definitionParts {
display: grid;
gap: 0.25rem;
}
.localDetails span,
.definitionParts span {
display: flex;
align-items: center;
gap: 0.25rem;
color: var(--diagram-muted);
font-size: 0.53rem;
line-height: 1.3;
}
.definitionParts svg {
flex: none;
color: var(--ifm-color-primary);
}
.diagramNote {
padding-top: 0.75rem;
border-top: 1px solid var(--diagram-line);
color: var(--diagram-muted);
font-size: 0.68rem;
line-height: 1.45;
}
.factList {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.4rem;
}
.factList span {
display: flex;
align-items: center;
gap: 0.35rem;
min-width: 0;
padding: 0.45rem;
border: 1px solid var(--diagram-line);
border-radius: 0.4rem;
color: var(--diagram-muted);
background: var(--diagram-surface);
font-size: 0.56rem;
line-height: 1.3;
}
.factList svg {
flex: none;
color: var(--ifm-color-primary);
}
.boardSource {
display: flex;
align-items: center;
justify-self: center;
gap: 0.55rem;
min-width: 14rem;
padding: 0.65rem 0.8rem;
border: 1px solid var(--diagram-line);
border-radius: 0.55rem;
background: var(--diagram-surface);
}
.largeIcon {
width: 2.25rem;
height: 2.25rem;
border-radius: 0.5rem;
}
.boardSource > span:last-child {
display: grid;
}
.boardSource strong {
font-size: 0.78rem;
}
.boardSource small {
color: var(--diagram-muted);
font-size: 0.59rem;
}
.layoutGrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.55rem;
}
.layoutCard {
display: grid;
gap: 0.55rem;
min-width: 0;
padding: 0.65rem;
border: 1px solid var(--diagram-line);
border-radius: 0.55rem;
margin: 0;
background: var(--diagram-surface);
}
.layoutHeading {
display: flex;
align-items: center;
gap: 0.45rem;
}
.layoutHeading > span:first-child {
display: grid;
flex: none;
place-items: center;
width: 1.65rem;
height: 1.65rem;
border: 1px solid var(--diagram-line);
border-radius: 0.4rem;
color: var(--ifm-color-primary);
background: var(--diagram-panel);
}
.layoutHeading > span:last-child {
display: grid;
min-width: 0;
}
.layoutHeading strong {
font-size: 0.7rem;
}
.layoutHeading code {
padding: 0;
border: 0;
color: var(--diagram-muted);
background: transparent;
font-size: 0.54rem;
white-space: normal;
}
.layoutCard > small {
color: var(--diagram-muted);
font-size: 0.55rem;
}
.miniBoard {
display: grid;
grid-auto-rows: 1.2rem;
gap: 0.22rem;
min-height: 4.25rem;
padding: 0.35rem;
border: 1px dashed var(--diagram-line);
border-radius: 0.35rem;
background: var(--diagram-panel);
}
.miniTile,
.miniRail {
display: block;
border: 1px solid var(--diagram-line);
border-radius: 0.22rem;
background: var(--diagram-surface);
}
.miniTile:nth-child(2) {
border-color: var(--ifm-color-primary);
}
.mobileBoard {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.mobileBoard .miniRail {
display: none;
}
.mobileBoard .miniTile:nth-child(3),
.mobileBoard .miniTile:nth-child(5) {
grid-column: span 2;
}
.baseBoard {
grid-template-columns: 0.7fr repeat(4, minmax(0, 1fr));
}
.baseBoard .miniRail {
grid-row: 1 / span 3;
}
.baseBoard .miniTile:nth-child(3),
.baseBoard .miniTile:nth-child(4) {
grid-column: span 2;
}
.customBoard {
grid-template-columns: repeat(5, minmax(0, 1fr)) 0.7fr;
}
.customBoard .miniRail {
grid-column: 6;
grid-row: 1 / span 3;
}
.customBoard .miniTile:nth-child(3) {
grid-column: span 3;
}
.customBoard .miniTile:nth-child(5) {
grid-column: span 2;
}
@media (max-width: 900px) {
.threeStepLane,
.fourStepLane {
grid-template-columns: minmax(0, 1fr);
}
.node {
min-height: auto;
}
.factList,
.layoutGrid {
grid-template-columns: minmax(0, 1fr);
}
.miniBoard {
min-height: 5rem;
}
}
@@ -0,0 +1,423 @@
import type { ReactNode } from "react";
import Link from "@docusaurus/Link";
import {
IconApi,
IconBox,
IconBraces,
IconBrowser,
IconBuildingStore,
IconCode,
IconDeviceDesktop,
IconDeviceMobile,
IconKey,
IconLayoutBoard,
IconMessages,
IconPlugConnected,
IconRobot,
IconServer,
IconShieldCheck,
IconTool,
IconUser,
IconWorld,
} from "@tabler/icons-react";
import { sonarrIntegration } from "@site/docs/integrations/sonarr";
import { FlowConnector } from "@site/src/components/ui/flow-connector";
import styles from "./technical-diagrams.module.css";
const sonarrIconUrl = getIconUrl(sonarrIntegration.iconUrl);
export function AppFlowDiagram() {
return (
<figure className={styles.diagram} aria-label="How an app record is used on a board">
<div className={styles.threeStepLane}>
<DiagramNode icon={<IconBox size={19} />} title="App record" detail="Saved once in Homarr">
<div className={styles.pillList}>
<span>Name</span>
<span>Icon</span>
<span>URL</span>
<span>Ping URL · optional</span>
</div>
</DiagramNode>
<FlowConnector label="place" />
<DiagramNode icon={<IconLayoutBoard size={19} />} title="Board" detail="App tile or Bookmarks widget" />
<FlowConnector label="click" animationDelay={350} />
<DiagramNode icon={<IconBrowser size={19} />} title="Browser" detail="Opens the app URL directly" />
</div>
<DiagramNote>
If a Ping URL is set, the Homarr server checks that address for status. Clicking the tile still opens the app
URL.
</DiagramNote>
</figure>
);
}
export function IntegrationRoutesDiagram() {
return (
<figure className={styles.diagram} aria-label="Browser and server routes from Homarr to Sonarr">
<div className={styles.routeBlock}>
<RouteHeading icon={<IconBrowser size={16} />} title="Open the service" detail="Browser route" />
<div className={styles.threeStepLane}>
<DiagramNode icon={<IconLayoutBoard size={19} />} title="App tile" detail="Click from a board" />
<FlowConnector label="opens" />
<DiagramNode icon={<IconWorld size={19} />} title="App URL" detail="Browser-facing address">
<code>https://sonarr.example.com</code>
</DiagramNode>
<FlowConnector animationDelay={350} />
<DiagramNode
icon={<img src={sonarrIconUrl} alt="" />}
title="Sonarr UI"
detail="The browser connects directly"
href="/docs/integrations/sonarr"
/>
</div>
</div>
<div className={styles.routeBlock}>
<RouteHeading icon={<IconServer size={16} />} title="Use service data" detail="Server route" />
<div className={styles.threeStepLane}>
<DiagramNode icon={<IconApi size={19} />} title="Widget" detail="Requests data or an action" />
<FlowConnector label="Homarr" />
<DiagramNode
icon={<IconPlugConnected size={19} />}
title="Integration"
detail="URL and encrypted credentials"
href="/docs/management/integrations"
>
<code>http://sonarr:8989</code>
</DiagramNode>
<FlowConnector label="API" animationDelay={350} />
<DiagramNode
icon={<img src={sonarrIconUrl} alt="" />}
title="Sonarr API"
detail="The Homarr server connects"
href="/docs/integrations/sonarr"
/>
</div>
</div>
<DiagramNote>
Linking the app to the integration gives integration-backed widgets the browser-facing destination.
</DiagramNote>
</figure>
);
}
export function BoardLayoutsDiagram() {
return (
<figure className={styles.diagram} aria-label="How one board selects independent responsive layouts">
<div className={styles.boardSource}>
<span className={styles.largeIcon}>
<IconLayoutBoard size={23} aria-hidden="true" />
</span>
<span>
<strong>One board</strong>
<small>The same apps, widgets, and Containers</small>
</span>
</div>
<FlowConnector label="highest matching breakpoint" orientation="vertical" />
<div className={styles.layoutGrid}>
<LayoutCard
icon={<IconDeviceMobile size={18} />}
title="Mobile"
range="starts at 0px"
detail="Own positions · no rails"
variant="mobile"
/>
<LayoutCard
icon={<IconDeviceDesktop size={18} />}
title="Base"
range="starts at 768px"
detail="Own positions · rails available"
variant="base"
/>
<LayoutCard
icon={<IconLayoutBoard size={18} />}
title="Custom"
range="unique breakpoint"
detail="Own positions · rails available"
variant="custom"
/>
</div>
<DiagramNote>Moving or resizing an item in one layout does not change its coordinates in the others.</DiagramNote>
</figure>
);
}
export function AssistantFlowDiagram() {
return (
<figure className={styles.diagram} aria-label="How Homarr Assistant uses a model provider and Homarr tools">
<div className={styles.routeBlock}>
<RouteHeading icon={<IconMessages size={16} />} title="Conversation" detail="Every request" />
<div className={styles.threeStepLane}>
<DiagramNode icon={<IconUser size={19} />} title="You" detail="Prompt, attachment, or @ reference" />
<FlowConnector />
<DiagramNode
icon={<IconRobot size={19} />}
title="Assistant server"
detail="Loads live context and tool definitions"
/>
<FlowConnector animationDelay={350} />
<DiagramNode
icon={<IconWorld size={19} />}
title="Provider + model"
detail="Instance-wide provider; model may vary by conversation"
>
<div className={styles.pillList}>
<Link to="/docs/workshop/homarr-provider">Homarr</Link>
<span>Hosted</span>
<span>Local</span>
<span>Custom</span>
</div>
</DiagramNode>
</div>
</div>
<div className={styles.routeBlock}>
<RouteHeading icon={<IconTool size={16} />} title="When the model calls a tool" detail="Only when needed" />
<div className={styles.threeStepLane}>
<DiagramNode icon={<IconTool size={19} />} title="Tool request" detail="Read data or make a change" />
<FlowConnector />
<DiagramNode
icon={<IconShieldCheck size={19} />}
title="Permission boundary"
detail="Current user; approval for changes by default"
/>
<FlowConnector animationDelay={350} />
<DiagramNode
icon={<IconLayoutBoard size={19} />}
title="Homarr resources"
detail="Boards · apps · integrations · widgets · services"
href="/docs/management/mcp"
/>
</div>
</div>
<DiagramNote>
Tool results return to the model; the final answer streams back to the same conversation. The Homarr provider
routes model requests through Workshop. Other providers connect directly from the Homarr server.
</DiagramNote>
</figure>
);
}
export function HomarrProviderDiagram() {
return (
<figure className={styles.diagram} aria-label="How the Homarr Assistant provider routes model requests">
<div className={styles.fourStepLane}>
<DiagramNode icon={<IconMessages size={19} />} title="Assistant" detail="Per-user conversation" />
<FlowConnector label="request" />
<DiagramNode icon={<IconServer size={19} />} title="Homarr server" detail="Context, permissions, and tools" />
<FlowConnector label="token" animationDelay={350} />
<DiagramNode
icon={<IconBuildingStore size={19} />}
title="Workshop provider"
detail="Quota and homarr/model routing"
href="/docs/workshop"
/>
<FlowConnector label="model" animationDelay={700} />
<DiagramNode icon={<IconWorld size={19} />} title="Upstream model" detail="OpenRouter-compatible endpoint" />
</div>
<div className={styles.factList}>
<span>
<IconKey size={15} aria-hidden="true" /> No administrator API key
</span>
<span>
<IconApi size={15} aria-hidden="true" /> 50 request units per user and UTC day
</span>
<span>
<IconShieldCheck size={15} aria-hidden="true" /> Workshop does not store prompts or responses
</span>
</div>
<DiagramNote>
Tool calls still execute inside your Homarr instance with the signed-in user's permissions.
</DiagramNote>
</figure>
);
}
export function WorkshopFlowDiagram() {
return (
<figure className={styles.diagram} aria-label="How content moves through the Community Workshop">
<div className={styles.fourStepLane}>
<DiagramNode icon={<IconCode size={19} />} title="Author" detail="Build shareable content">
<div className={styles.pillList}>
<span>Custom Widget JSON</span>
<span>Custom CSS</span>
</div>
</DiagramNode>
<FlowConnector label="publish" />
<DiagramNode
icon={<IconBuildingStore size={19} />}
title="Workshop"
detail="Source, screenshots, and revision"
href="pathname:///workshop"
/>
<FlowConnector label="inspect" animationDelay={350} />
<DiagramNode
icon={<IconShieldCheck size={19} />}
title="Review + import"
detail="Validate source and requested access"
/>
<FlowConnector label="local" animationDelay={700} />
<DiagramNode icon={<IconLayoutBoard size={19} />} title="Your Homarr" detail="Configure and place on a board">
<div className={styles.localDetails}>
<span>Widget → local URLs + secrets</span>
<span>CSS → board custom CSS</span>
</div>
</DiagramNode>
</div>
<DiagramNote>
Workshop stores the shareable definition. Instance-specific URLs and credentials stay local.
</DiagramNote>
</figure>
);
}
export function CustomWidgetFlowDiagram() {
return (
<figure className={styles.diagram} aria-label="How a Custom Widget definition becomes a board tile">
<div className={styles.threeStepLane}>
<DiagramNode
icon={<IconBraces size={19} />}
title="Widget definition"
detail="homarr-custom-widget-v2"
href="/docs/management/custom-widgets/authoring"
>
<div className={styles.definitionParts}>
<span>
<IconApi size={14} /> API sources
</span>
<span>
<IconTool size={14} /> Queries + actions
</span>
<span>
<IconCode size={14} /> Safe JSX
</span>
</div>
</DiagramNode>
<FlowConnector label="validate" />
<DiagramNode
icon={<IconServer size={19} />}
title="Homarr runtime"
detail="Requests on the server; JSX in a restricted renderer"
href="/docs/management/custom-widgets/requests-and-security"
/>
<FlowConnector label="render" animationDelay={350} />
<DiagramNode icon={<IconLayoutBoard size={19} />} title="Board tile" detail="Live data and declared actions" />
</div>
<DiagramNote>
Source credentials are stored separately and excluded from exports, prompts, and Workshop submissions.
</DiagramNote>
</figure>
);
}
function RouteHeading({ icon, title, detail }: { icon: ReactNode; title: string; detail: string }) {
return (
<div className={styles.routeHeading}>
<span>{icon}</span>
<strong>{title}</strong>
<small>{detail}</small>
</div>
);
}
function DiagramNode({
icon,
title,
detail,
href,
children,
}: {
icon: ReactNode;
title: string;
detail: string;
href?: string;
children?: ReactNode;
}) {
const content = (
<>
<div className={styles.nodeHeader}>
<span className={styles.nodeIcon}>{icon}</span>
<span className={styles.nodeCopy}>
<strong>{title}</strong>
<small>{detail}</small>
</span>
</div>
{children && <div className={styles.nodeBody}>{children}</div>}
</>
);
if (href) {
return (
<Link className={`${styles.node} ${styles.nodeLink}`} to={href}>
{content}
</Link>
);
}
return <div className={styles.node}>{content}</div>;
}
function LayoutCard({
icon,
title,
range,
detail,
variant,
}: {
icon: ReactNode;
title: string;
range: string;
detail: string;
variant: "mobile" | "base" | "custom";
}) {
return (
<section className={styles.layoutCard}>
<div className={styles.layoutHeading}>
<span>{icon}</span>
<span>
<strong>{title}</strong>
<code>{range}</code>
</span>
</div>
<MiniBoard variant={variant} />
<small>{detail}</small>
</section>
);
}
function MiniBoard({ variant }: { variant: "mobile" | "base" | "custom" }) {
let className = styles.miniBoard;
className = `${className} ${styles[`${variant}Board`]}`;
return (
<div className={className} aria-hidden="true">
<span className={styles.miniRail} />
<span className={styles.miniTile} />
<span className={styles.miniTile} />
<span className={styles.miniTile} />
<span className={styles.miniTile} />
</div>
);
}
function DiagramNote({ children }: { children: ReactNode }) {
return <figcaption className={styles.diagramNote}>{children}</figcaption>;
}
function getIconUrl(iconUrl: typeof sonarrIntegration.iconUrl) {
if (typeof iconUrl === "string") return iconUrl;
return iconUrl.light;
}
@@ -0,0 +1,245 @@
.diagram {
--diagram-ink: var(--ifm-font-color-base);
--diagram-muted: var(--ifm-color-emphasis-700);
--diagram-panel: var(--ifm-background-surface-color);
--diagram-line: var(--ifm-color-emphasis-300);
margin: 0;
padding: 1rem;
border: 1px solid var(--diagram-line);
border-radius: 0.75rem;
color: var(--diagram-ink);
background: var(--diagram-panel);
}
.lane {
display: grid;
grid-template-columns: minmax(8.5rem, 1.1fr) 2.75rem minmax(8.5rem, 1fr) 2.75rem minmax(8.5rem, 1fr) 2.75rem minmax(
8.5rem,
1fr
);
align-items: stretch;
gap: 0.35rem;
}
.stage {
display: grid;
grid-template-rows: auto 1fr;
gap: 0.75rem;
min-width: 0;
padding: 0.7rem;
border: 1px solid var(--diagram-line);
border-radius: 0.55rem;
margin: 0;
background: var(--ifm-background-color);
}
.stageHeading {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--diagram-ink);
text-decoration: none;
}
a.stageHeading:hover {
color: var(--ifm-color-primary);
text-decoration: none;
}
.stageHeading > span:last-child {
display: grid;
min-width: 0;
}
.stageHeading strong {
font-size: 0.78rem;
}
.stageHeading small {
color: var(--diagram-muted);
font-size: 0.62rem;
line-height: 1.3;
}
.stageNumber {
display: grid;
flex: none;
place-items: center;
width: 1.45rem;
height: 1.45rem;
border: 1px solid var(--diagram-line);
border-radius: 50%;
color: var(--diagram-muted);
font-family: var(--font-mono);
font-size: 0.64rem;
font-weight: 700;
}
.serviceList,
.widgetList {
display: grid;
align-content: center;
gap: 0.35rem;
}
.service,
.widget {
display: flex;
align-items: center;
gap: 0.45rem;
min-width: 0;
padding: 0.4rem;
border: 1px solid var(--diagram-line);
border-radius: 0.4rem;
color: var(--diagram-ink);
background: var(--diagram-panel);
text-decoration: none;
}
.service:hover,
.widget:hover {
border-color: var(--ifm-color-primary);
color: var(--diagram-ink);
text-decoration: none;
}
.service img {
flex: none;
width: 1.25rem;
height: 1.25rem;
}
.widget svg {
flex: none;
color: var(--diagram-muted);
}
.service > span {
display: grid;
min-width: 0;
}
.service strong,
.widget span {
overflow: hidden;
font-size: 0.67rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.service code,
.integration code {
padding: 0;
border: 0;
color: var(--diagram-muted);
background: transparent;
font-size: 0.56rem;
white-space: nowrap;
}
.integration {
display: grid;
align-content: center;
justify-items: center;
gap: 0.35rem;
min-height: 5rem;
text-align: center;
}
.integration > svg {
color: var(--diagram-muted);
}
.integration > span {
color: var(--diagram-muted);
font-size: 0.61rem;
line-height: 1.35;
}
.boardCalendar {
display: grid;
align-content: center;
gap: 0.28rem;
min-height: 5rem;
padding: 0.42rem;
border: 1px solid var(--diagram-line);
border-radius: 0.4rem;
background: var(--diagram-panel);
}
.calendarHeader {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.3rem;
}
.calendarHeader strong {
font-size: 0.58rem;
}
.calendarHeader span {
color: var(--diagram-muted);
font-size: 0.44rem;
}
.calendarWeekdays,
.calendarDays {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 0.12rem;
}
.calendarWeekdays span {
color: var(--diagram-muted);
font-family: var(--font-mono);
font-size: 0.38rem;
text-align: center;
}
.calendarDays span {
position: relative;
display: grid;
place-items: center;
min-width: 0;
aspect-ratio: 1;
border-radius: 0.18rem;
color: var(--diagram-muted);
background: color-mix(in srgb, var(--diagram-line) 28%, transparent);
font-family: var(--font-mono);
font-size: 0.42rem;
}
.calendarDays span[data-event] {
color: var(--diagram-ink);
background: color-mix(in srgb, var(--diagram-line) 62%, transparent);
font-weight: 700;
}
.calendarDays span[data-event]::after {
position: absolute;
bottom: 0.08rem;
width: 0.12rem;
height: 0.12rem;
border-radius: 50%;
background: var(--diagram-muted);
content: "";
}
@media (max-width: 900px) {
.lane {
grid-template-columns: minmax(0, 1fr);
}
.serviceList,
.widgetList {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 430px) {
.serviceList,
.widgetList {
grid-template-columns: minmax(0, 1fr);
}
}
@@ -0,0 +1,154 @@
import type { ReactNode } from "react";
import Link from "@docusaurus/Link";
import { IconCalendar, IconDownload, IconPlugConnected } from "@tabler/icons-react";
import { qBittorentIntegration } from "@site/docs/integrations/q-bittorent";
import { sonarrIntegration } from "@site/docs/integrations/sonarr";
import { FlowConnector } from "@site/src/components/ui/flow-connector";
import styles from "./concept-flow.module.css";
const services = [
{
name: sonarrIntegration.name,
iconUrl: getIconUrl(sonarrIntegration.iconUrl),
endpoint: "sonarr:8989",
href: "/docs/integrations/sonarr",
},
{
name: qBittorentIntegration.name,
iconUrl: getIconUrl(qBittorentIntegration.iconUrl),
endpoint: "qbittorrent:8080",
href: "/docs/integrations/q-bittorent",
},
];
const widgets = [
{
name: "Calendar",
icon: IconCalendar,
href: "/docs/widgets/calendar",
},
{
name: "Downloads",
icon: IconDownload,
href: "/docs/widgets/downloads",
},
];
const calendarDays = Array.from({ length: 14 }, (_, index) => index + 1);
const calendarEventDays = new Set([3, 8, 12]);
function getIconUrl(iconUrl: typeof sonarrIntegration.iconUrl) {
if (typeof iconUrl === "string") return iconUrl;
return iconUrl.light;
}
export function ConceptFlow() {
return (
<figure className={styles.diagram} aria-label="How Homarr connects services to a board">
<div className={styles.lane}>
<Stage number="1" title="Service" detail="API endpoint">
<div className={styles.serviceList}>
{services.map((service) => (
<Link className={styles.service} to={service.href} key={service.name}>
<img src={service.iconUrl} alt="" />
<span>
<strong>{service.name}</strong>
<code>{service.endpoint}</code>
</span>
</Link>
))}
</div>
</Stage>
<FlowConnector />
<Stage number="2" title="Integration" detail="Server-side connection" href="/docs/management/integrations">
<div className={styles.integration}>
<IconPlugConnected size={26} aria-hidden="true" />
<code>URL + credentials</code>
<span>Fetch data and run actions</span>
</div>
</Stage>
<FlowConnector animationDelay={350} />
<Stage number="3" title="Widget" detail="Data and actions" href="/docs/category/widgets">
<div className={styles.widgetList}>
{widgets.map((widget) => {
const Icon = widget.icon;
return (
<Link className={styles.widget} to={widget.href} key={widget.name}>
<Icon size={17} aria-hidden="true" />
<span>{widget.name}</span>
</Link>
);
})}
</div>
</Stage>
<FlowConnector animationDelay={700} />
<Stage number="4" title="Board" detail="Placed in the grid" href="/docs/management/boards">
<div className={styles.boardCalendar} aria-hidden="true">
<div className={styles.calendarHeader}>
<strong>September</strong>
<span>3 events</span>
</div>
<div className={styles.calendarWeekdays}>
{["M", "T", "W", "T", "F", "S", "S"].map((day, index) => (
<span key={`${day}-${index}`}>{day}</span>
))}
</div>
<div className={styles.calendarDays}>
{calendarDays.map((day) => (
<span data-event={calendarEventDays.has(day) || undefined} key={day}>
{day}
</span>
))}
</div>
</div>
</Stage>
</div>
</figure>
);
}
function Stage({
number,
title,
detail,
href,
children,
}: {
number: string;
title: string;
detail: string;
href?: string;
children?: ReactNode;
}) {
const heading = (
<>
<span className={styles.stageNumber}>{number}</span>
<span>
<strong>{title}</strong>
<small>{detail}</small>
</span>
</>
);
return (
<section className={styles.stage}>
{href ? (
<Link className={styles.stageHeading} to={href}>
{heading}
</Link>
) : (
<div className={styles.stageHeading}>{heading}</div>
)}
{children}
</section>
);
}
@@ -0,0 +1,232 @@
.overview {
--start-ink: var(--ifm-font-color-base);
--start-muted: var(--ifm-color-emphasis-700);
--start-panel: var(--ifm-background-surface-color);
--start-line: var(--ifm-color-emphasis-300);
--start-coral: #fa5252;
display: grid;
gap: 1.5rem;
margin: 1.5rem 0 3rem;
}
.intro {
padding-left: 1rem;
border-left: 3px solid var(--ifm-color-primary);
}
.intro h2 {
margin: 0 0 0.4rem;
font-size: 1.5rem;
}
.intro p {
max-width: 48rem;
margin: 0;
color: var(--start-muted);
line-height: 1.55;
}
.editor {
display: grid;
gap: 0.9rem;
padding-top: 0.25rem;
}
.editorHeading {
display: flex;
align-items: end;
justify-content: space-between;
gap: 1rem;
}
.editorHeading h3 {
margin: 0 0 0.25rem;
font-size: 1.1rem;
}
.editorHeading p {
margin: 0;
color: var(--start-muted);
font-size: 0.84rem;
}
.editorHeading > a {
display: inline-flex;
flex: none;
align-items: center;
gap: 0.25rem;
font-size: 0.78rem;
font-weight: 700;
}
.editorSteps {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.65rem;
margin: 0;
padding: 0;
list-style: none;
}
.editorStep {
overflow: hidden;
display: grid;
grid-template-rows: auto 1fr;
min-width: 0;
border: 1px solid var(--start-line);
border-radius: 0.65rem;
background: var(--start-panel);
}
.stepCopy {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.65rem;
}
.stepCopy > span {
display: grid;
flex: none;
place-items: center;
width: 1.4rem;
height: 1.4rem;
border: 1px solid var(--start-line);
border-radius: 50%;
color: var(--start-muted);
font-family: var(--font-mono);
font-size: 0.62rem;
font-weight: 700;
}
.stepCopy > div {
display: grid;
min-width: 0;
}
.stepCopy strong {
font-size: 0.78rem;
}
.stepCopy small {
color: var(--start-muted);
font-size: 0.64rem;
line-height: 1.35;
}
.stepMedia {
display: grid;
place-items: center;
min-height: 9rem;
padding: 0.4rem;
border-top: 1px solid var(--start-line);
background: #202020;
}
.stepMedia img {
display: block;
width: 100%;
max-height: 12rem;
object-fit: contain;
}
.stepMedia img[data-fit="cover"] {
height: 100%;
object-fit: cover;
object-position: top;
}
.installPaths {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.8rem;
margin: 1rem 0 1.5rem;
}
.installPath {
position: relative;
display: grid;
gap: 0.45rem;
min-height: 10rem;
padding: 1.1rem;
border: 1px solid var(--ifm-color-emphasis-400);
border-radius: 0.9rem;
color: var(--start-ink);
background: var(--start-panel);
box-shadow: 0 3px 10px rgb(20 25 35 / 8%);
text-decoration: none;
transition:
border-color 160ms ease,
transform 160ms ease,
box-shadow 160ms ease;
}
.installPath:hover {
border-color: rgb(250 82 82 / 55%);
color: var(--start-ink);
text-decoration: none;
transform: translateY(-2px);
box-shadow: 0 12px 28px rgb(20 25 35 / 8%);
}
.installPath > span {
color: #c92f2f;
font-family: var(--font-mono);
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
:global([data-theme="dark"]) .installPath > span {
color: var(--ifm-color-primary-light);
}
.installPath strong {
font-size: 1rem;
}
.installPath small {
color: var(--start-muted);
line-height: 1.45;
}
.installPath > svg {
position: absolute;
right: 0.9rem;
bottom: 0.9rem;
color: var(--start-coral);
}
.recommendedPath {
border-color: var(--ifm-color-primary);
background: var(--start-panel);
}
@media (max-width: 800px) {
.editorHeading {
align-items: start;
}
.editorSteps {
grid-template-columns: minmax(0, 1fr);
}
.stepMedia img {
max-height: 18rem;
}
.installPaths {
grid-template-columns: 1fr;
}
.installPath {
min-height: 8rem;
}
}
@media (prefers-reduced-motion: reduce) {
.installPath {
transition: none;
}
}
@@ -0,0 +1,119 @@
import Link from "@docusaurus/Link";
import { IconArrowRight } from "@tabler/icons-react";
import addItem from "@site/docs/getting-started/img/manage-board-header-choose-item.png";
import moveItem from "@site/docs/getting-started/img/move-item.gif";
import resizeItem from "@site/docs/getting-started/img/resize-item.gif";
import { ConceptFlow } from "./concept-flow";
import styles from "./getting-started-overview.module.css";
const editingSteps = [
{
title: "Add",
description: "Choose an app, widget, or container.",
image: addItem,
alt: "Add item menu in board edit mode",
fit: "cover",
},
{
title: "Move",
description: "Drag the item to another grid position.",
image: moveItem,
alt: "Dragging an app tile on a board",
fit: "contain",
},
{
title: "Resize",
description: "Drag the resize handle to change its grid area.",
image: resizeItem,
alt: "Resizing an app tile on a board",
fit: "contain",
},
];
export function GettingStartedOverview() {
return (
<section className={styles.overview} aria-labelledby="getting-started-overview-title">
<header className={styles.intro}>
<h2 id="getting-started-overview-title">How Homarr works</h2>
<p>Services expose data, integrations connect to them, widgets use the data, and boards arrange the widgets.</p>
</header>
<ConceptFlow />
<section className={styles.editor} aria-labelledby="board-editor-title">
<div className={styles.editorHeading}>
<div>
<h3 id="board-editor-title">Board edit mode</h3>
<p>Add items, move them, and resize them on the grid. Each viewport can have its own layout.</p>
</div>
<Link to="/docs/management/boards">
Board docs <IconArrowRight size={15} aria-hidden="true" />
</Link>
</div>
<ol className={styles.editorSteps}>
{editingSteps.map((step, index) => (
<li className={styles.editorStep} key={step.title}>
<div className={styles.stepCopy}>
<span>{index + 1}</span>
<div>
<strong>{step.title}</strong>
<small>{step.description}</small>
</div>
</div>
<div className={styles.stepMedia}>
<img src={step.image} alt={step.alt} data-fit={step.fit} loading="lazy" />
</div>
</li>
))}
</ol>
</section>
</section>
);
}
const installationPaths = [
{
eyebrow: "Recommended",
title: "Docker Compose",
description: "General-purpose deployment with a persistent appdata volume.",
href: "/docs/getting-started/installation/docker",
recommended: true,
},
{
eyebrow: "Kubernetes",
title: "Helm",
description: "Deploy Homarr into an existing cluster.",
href: "/docs/getting-started/installation/helm",
recommended: false,
},
{
eyebrow: "Platforms",
title: "NAS and hosting guides",
description: "Unraid, TrueNAS, Synology, Portainer, Proxmox, and more.",
href: "/docs/category/installation-1",
recommended: false,
},
];
export function InstallationPaths() {
return (
<div className={styles.installPaths}>
{installationPaths.map((path) => {
let className = styles.installPath;
if (path.recommended) className = `${className} ${styles.recommendedPath}`;
return (
<Link className={className} to={path.href} key={path.href}>
<span>{path.eyebrow}</span>
<strong>{path.title}</strong>
<small>{path.description}</small>
<IconArrowRight size={16} aria-hidden="true" />
</Link>
);
})}
</div>
);
}
@@ -0,0 +1,175 @@
.connector {
--flow-accent: var(--ifm-color-primary);
--flow-coral: #f17865;
--flow-head: var(--diagram-ink, var(--ifm-font-color-base));
--flow-line: var(--diagram-line, var(--ifm-color-emphasis-300));
--flow-rail: color-mix(in srgb, var(--flow-line) 68%, var(--flow-head));
position: relative;
display: grid;
align-content: center;
justify-items: center;
gap: 0.22rem;
min-width: 0;
color: var(--flow-head);
}
.label {
z-index: 1;
max-width: 100%;
padding: 0.17rem 0.36rem;
border: 1px solid color-mix(in srgb, var(--flow-accent) 32%, var(--flow-line));
border-radius: 999px;
color: var(--diagram-muted, var(--ifm-color-emphasis-700));
background: var(--diagram-panel, var(--ifm-background-surface-color));
font-family: var(--font-mono);
font-size: 0.5rem;
font-weight: 650;
line-height: 1;
text-align: center;
white-space: nowrap;
}
.track {
position: relative;
overflow: hidden;
display: block;
width: calc(100% + 0.5rem);
height: 1.1rem;
margin-inline: -0.25rem;
}
.rail {
position: absolute;
top: 50%;
right: 0.18rem;
left: 0.18rem;
height: 2px;
background: var(--flow-rail);
transform: translateY(-50%);
}
.beam {
position: absolute;
top: calc(50% - 1.5px);
left: 0.23rem;
width: 48%;
height: 3px;
border-radius: 999px;
background: linear-gradient(90deg, transparent, var(--flow-coral) 34%, var(--flow-accent) 68%, transparent);
filter: drop-shadow(0 0 0.28rem color-mix(in srgb, var(--flow-accent) 48%, transparent));
animation: flow-beam-horizontal 2.4s cubic-bezier(0.4, 0, 0.2, 1) infinite;
animation-delay: var(--flow-delay);
}
.vertical {
min-height: 3rem;
}
.vertical .track {
width: 1.1rem;
height: 2.6rem;
margin-inline: 0;
}
.vertical .rail {
top: 0.16rem;
right: auto;
bottom: 0.16rem;
left: 50%;
width: 2px;
height: auto;
transform: translateX(-50%);
}
.vertical .beam {
top: 0.23rem;
left: calc(50% - 1.5px);
width: 3px;
height: 48%;
background: linear-gradient(180deg, transparent, var(--flow-coral) 46%, var(--flow-accent));
animation-name: flow-beam-vertical;
}
.visuallyHidden {
position: absolute;
overflow: hidden;
width: 1px;
height: 1px;
padding: 0;
border: 0;
margin: -1px;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@keyframes flow-beam-horizontal {
0% {
opacity: 0;
transform: translateX(-35%);
}
18%,
72% {
opacity: 1;
}
100% {
opacity: 0;
transform: translateX(155%);
}
}
@keyframes flow-beam-vertical {
0% {
opacity: 0;
transform: translateY(-35%);
}
18%,
72% {
opacity: 1;
}
100% {
opacity: 0;
transform: translateY(155%);
}
}
@media (max-width: 900px) {
.connector:not(.vertical) {
min-height: 3rem;
}
.connector:not(.vertical) .track {
width: 1.1rem;
height: 2.6rem;
margin-inline: 0;
}
.connector:not(.vertical) .rail {
top: 0.16rem;
right: auto;
bottom: 0.16rem;
left: 50%;
width: 2px;
height: auto;
transform: translateX(-50%);
}
.connector:not(.vertical) .beam {
top: 0.23rem;
left: calc(50% - 1.5px);
width: 3px;
height: 48%;
background: linear-gradient(180deg, transparent, var(--flow-coral) 46%, var(--flow-accent));
animation-name: flow-beam-vertical;
}
}
@media (prefers-reduced-motion: reduce) {
.beam {
opacity: 1;
animation: none;
}
}
@@ -0,0 +1,28 @@
import type { CSSProperties } from "react";
import styles from "./flow-connector.module.css";
type FlowConnectorProps = {
label?: string;
animationDelay?: number;
orientation?: "responsive" | "vertical";
};
export function FlowConnector({ label, animationDelay = 0, orientation = "responsive" }: FlowConnectorProps) {
let className = styles.connector;
if (orientation === "vertical") className = `${className} ${styles.vertical}`;
const style = { "--flow-delay": `${animationDelay}ms` } as CSSProperties;
let accessibleLabel = <span className={styles.visuallyHidden}>then</span>;
if (label) accessibleLabel = <small className={styles.label}>{label}</small>;
return (
<span className={className} style={style}>
{accessibleLabel}
<span className={styles.track} aria-hidden="true">
<span className={styles.rail} />
<span className={styles.beam} />
</span>
</span>
);
}
+1 -3
View File
@@ -1,9 +1,7 @@
export const AddingWidget = () => {
return (
<p>
You can find how to add the widget on the{" "}
<a href="../../getting-started/after-the-installation#embed-integration-data-using-widgets">Widgets</a>{" "}
documentation page.
Add the widget from the board&apos;s <strong>Add board content</strong> menu in edit mode.
</p>
);
};