feat(docs): migrate documentation site into monorepo (#5912)

This commit is contained in:
Thomas authored and GitHub committed 2026-06-11 18:48:39 +02:00
1 parent 3e464a76f2
commit 9e421c67bd
605 files changed
+26764 -219

No files matched your search

+170
View File
@@ -0,0 +1,170 @@
import { useLocation } from "@docusaurus/router";
import React, { useEffect } from "react";
export function Carbon() {
const ref = React.useRef<HTMLDivElement>(null!);
const location = useLocation();
if (process.env.NODE_ENV === "development") {
return null;
}
useEffect(() => {
const serve = "CWBDTKQM";
const placement = "homarrdev";
ref.current.innerHTML = "";
const s = document.createElement("script");
s.id = "_carbonads_js";
s.src = `//cdn.carbonads.com/carbon.js?serve=${serve}&placement=${placement}&format=cover`;
ref.current.appendChild(s);
}, [location]);
return (
<>
<div ref={ref} data-visual-test="blackout" className="flex flex-col m-4 space-y-2 argos-ignore" />
</>
);
}
// Variant 1: Strict re-initialization – remove prior scripts and injected nodes, then load cover
export function CarbonCoverStrict() {
const ref = React.useRef<HTMLDivElement>(null!);
const location = useLocation();
if (process.env.NODE_ENV === "development") {
return null;
}
useEffect(() => {
const serve = "CWBDTKQM";
const placement = "homarrdev";
// Clear our local container
ref.current.innerHTML = "";
// Remove any globally injected Carbon ad containers
Array.from(document.querySelectorAll('[id^="carbonads"], .carbonads')).forEach(
(el) => el.parentElement && el.parentElement.removeChild(el),
);
// Remove existing script if present
const existing = document.getElementById("_carbonads_js");
if (existing && existing.parentElement) existing.parentElement.removeChild(existing);
// Reset possible global handle
try {
delete (window as any)._carbonads;
} catch {}
// Inject fresh cover script into our container
const s = document.createElement("script");
s.id = "_carbonads_js";
s.src = `https://cdn.carbonads.com/carbon.js?serve=${serve}&placement=${placement}&format=cover`;
ref.current.appendChild(s);
}, [location]);
return (
<>
<style>
{`
#carbon-responsive-wrap * { margin: initial; padding: initial; }
`}
</style>
<div
ref={ref}
id="carbonads"
data-selector="carbonads"
className="bg-background flex flex-col m-4 space-y-2 carbonads argos-ignore"
/>
</>
);
}
// Variant 2: Observer – load cover then keep only the latest injected ad node
export function CarbonCoverObserver() {
const ref = React.useRef<HTMLDivElement>(null!);
const location = useLocation();
if (process.env.NODE_ENV === "development") {
return null;
}
useEffect(() => {
const serve = "CWBDTKQM";
const placement = "homarrdev";
ref.current.innerHTML = "";
const s = document.createElement("script");
s.id = "_carbonads_js";
s.src = `https://cdn.carbonads.com/carbon.js?serve=${serve}&placement=${placement}&format=cover`;
ref.current.appendChild(s);
// Observe body and ensure we keep only the most recent carbon container
const trim = () => {
const nodes = Array.from(document.querySelectorAll('[id^="carbonads"]')) as HTMLElement[];
if (nodes.length > 1) {
// Keep the last (most recently inserted); remove the rest
nodes.slice(0, -1).forEach((n) => n.parentElement && n.parentElement.removeChild(n));
}
};
const observer = new MutationObserver(() => trim());
observer.observe(document.body, { childList: true, subtree: true });
// Initial pass
trim();
return () => observer.disconnect();
}, [location]);
return (
<>
<style>
{`
#carbon-responsive-wrap * { margin: initial; padding: initial; }
`}
</style>
<div
ref={ref}
id="carbonads"
data-selector="carbonads"
className="bg-background flex flex-col m-4 space-y-2 carbonads argos-ignore"
/>
</>
);
}
// Variant 3: CSS-only – hide the first instance so only the second (cover) remains visible
export function CarbonCoverCssOnlySecond() {
const ref = React.useRef<HTMLDivElement>(null!);
const location = useLocation();
if (process.env.NODE_ENV === "development") {
return null;
}
useEffect(() => {
const serve = "CWBDTKQM";
const placement = "homarrdev";
ref.current.innerHTML = "";
const s = document.createElement("script");
s.id = "_carbonads_js";
s.src = `https://cdn.carbonads.com/carbon.js?serve=${serve}&placement=${placement}&format=cover`;
ref.current.appendChild(s);
}, [location]);
return (
<>
<style>
{`
/* Hide the first injected container; show subsequent one (cover) */
#carbon-responsive-wrap { display: none; }
#carbon-responsive-wrap * { margin: initial; padding: initial; }
`}
</style>
<div
ref={ref}
id="carbonads"
data-selector="carbonads"
className="bg-background flex flex-col m-4 space-y-2 carbonads argos-ignore"
/>
</>
);
}
@@ -0,0 +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.
</p>
);
};
@@ -0,0 +1,28 @@
import { IntegrationDefinition } from "@site/src/types";
import { DocsHeader } from "../ui/header";
import { useColorMode } from "@docusaurus/theme-common";
interface IntegrationHeaderProps {
integration: IntegrationDefinition;
categories: string[];
}
export const IntegrationHeader = (props: IntegrationHeaderProps) => {
const { isDarkTheme } = useColorMode();
return (
<DocsHeader
title={props.integration.name}
description={props.integration.description}
icon={<img src={getIntegrationIconUrl(props.integration, isDarkTheme)} width={48} height={48} />}
categories={props.categories}
/>
);
};
export const getIntegrationIconUrl = (integration: IntegrationDefinition, isDarkTheme: boolean): string =>
typeof integration.iconUrl === "string"
? integration.iconUrl
: isDarkTheme
? integration.iconUrl.dark
: integration.iconUrl.light;
@@ -0,0 +1,169 @@
import { IconCode, IconKey, IconKeyOff, IconLink, IconPassword, IconPlug, IconUser } from "@tabler/icons-react";
import TabItem from "@theme/TabItem";
import Tabs from "@theme/Tabs";
import { ReactNode } from "react";
import Alert from "@theme/Admonition";
const secretKinds = {
apiKey: {
name: "API Key",
description: "API Key from the service for authentication.",
icon: IconKey,
},
username: {
name: "Username",
description: "Account username for authentication.",
icon: IconUser,
},
password: {
name: "Password",
description: "Account password for authentication.",
icon: IconPassword,
},
tokenId: {
name: "Token ID",
description: "Token ID used for authentication",
},
realm: {
name: "Realm",
description: "The realm used for authentication, most of the time this is pve",
},
topic: {
name: "Topic",
description: "The topic to from which notifications should be retrieved.",
},
personalAccessToken: {
name: "Personal Access Token",
description: "A personal access token for authentication.",
icon: IconKey,
},
opnsenseApiKey: {
name: "Api Key (Key)",
description: "The Key part of the API Key for authentication.",
icon: IconKey,
},
opnsenseApiSecret: {
name: "Api Key (Secret)",
description: "The Secret part of the API Key for authentication.",
icon: IconPassword,
},
githubAppId: { name: "App ID", description: "The ID of the GitHub App", icon: IconCode },
githubInstallationId: {
name: "Installation ID",
description: "The ID of the GitHub Installation",
icon: IconPlug,
},
privateKey: {
name: "Private Key",
description: "The private key for authentication",
icon: IconKey,
},
url: {
name: "Url",
description: "The url of the service",
icon: IconLink,
},
slug: {
name: "Slug",
description: "The slug or path identifier for the resource",
icon: IconLink,
},
};
type SecretKind = keyof typeof secretKinds;
interface IntegrationSecretsProps {
secrets: {
tabLabel?: string;
credentials: SecretKind[];
steps: ReactNode[];
header?: ReactNode;
body?: ReactNode;
footer?: ReactNode;
}[];
}
export const IntegrationSecrets = ({ secrets }: IntegrationSecretsProps) => {
return (
<div className="flex flex-col gap-4 mt-6 w-full">
<div className="flex gap-6 rounded-xl border border-solid dark:border-[#333] border-[#e5e7eb] shadow-sm w-full items-center justify-between [&>*]:w-full">
<Tabs className="[&>li]:w-full [&>li]:justify-center">
{secrets.map((secret) => {
const key = secret.credentials.join("-");
const Icon =
secret.credentials.map((value) => secretKinds[value]).find((value) => "icon" in value)?.icon ||
IconKeyOff;
const tabLabel =
secret.tabLabel ??
(secret.credentials.length === 0
? "No Authentication"
: secret.credentials.map((value) => secretKinds[value].name).join(" & "));
return (
<TabItem
key={key}
label={
<div className="flex gap-2 items-center">
<Icon size={20} stroke={1.5} />
<span>{tabLabel}</span>
</div>
}
className="w-100"
value={key}
>
<div className="px-4 w-full gap-4 flex flex-col">
{secret.header}
{secret.credentials.length >= 1 && (
<table className="mb-0 w-full">
<thead>
<tr>
<th className="text-start min-w-32">Name</th>
<th className="text-start w-full">Description</th>
</tr>
</thead>
<tbody>
{secret.credentials.map((credential) => {
const kind = secretKinds[credential];
return (
<tr key={credential}>
<td>
<div>{kind.name}</div>
</td>
<td>{kind.description}</td>
</tr>
);
})}
</tbody>
</table>
)}
{secret.credentials.length === 0 && (
<Alert type="info" title="No credentials required">
This integration does not require any credentials to be set.
</Alert>
)}
{secret.body}
{secret.steps.length >= 1 && (
<>
<h3>Steps to retrieve the credentials:</h3>
<ol>
{secret.steps.map((step, index) => (
<li key={index} className="mb-2">
{step}
</li>
))}
</ol>
</>
)}
{secret.footer}
</div>
</TabItem>
);
})}
</Tabs>
</div>
</div>
);
};
@@ -0,0 +1,59 @@
import { WidgetDefinition } from "@site/src/types";
import { IconExternalLink, TablerIcon } from "@tabler/icons-react";
interface IntegrationCapabilitesProps {
items: (
| {
widget: WidgetDefinition;
note?: string;
}
| {
capability: {
icon: TablerIcon;
name: string;
description: string;
path: string;
};
note?: string;
}
)[];
}
export const IntegrationCapabilites = ({ items }: IntegrationCapabilitesProps) => {
return (
<div className="flex flex-col gap-4 mt-4 w-full">
<div className="flex flex-col gap-2">
{items.map((item) => {
const capability = "widget" in item ? item.widget : item.capability;
return (
<div
key={capability.name}
className="flex gap-6 rounded-xl border border-solid dark:border-[#333] border-[#e5e7eb] p-4 shadow-sm w-full items-center justify-between"
>
<div className="flex gap-6 items-center">
<div className="w-10 h-10 flex justify-center items-center bg-red-500 bg-opacity-10 rounded-md">
<capability.icon size={24} stroke={1.5} className="stroke-red-500" />
</div>
<div className="flex flex-col gap-0">
<span className="text-base font-bold">{capability.name}</span>
<span className="text-sm dark:text-[#999999] text-[#696969]">{capability.description}</span>
{item.note && <span className="text-xs text-yellow-500 mt-1">{item.note}</span>}
</div>
</div>
<a
href={capability.path}
className="border border-solid border-[#e5e7eb] dark:border-[#333] p-2 py-1 rounded-md gap-2 flex justify-center items-center hover:no-underline hover:bg-slate-100 dark:hover:bg-gray-800"
>
<IconExternalLink size={16} stroke={1.5} className="dark:stroke-white stroke-black" />
<span className="dark:text-white text-black font-medium text-sm">Details</span>
</a>
</div>
);
})}
</div>
</div>
);
};
@@ -0,0 +1,31 @@
import { useEffect, useState } from "react";
type Contributor = {
login: string;
avatar_url: string;
};
// do not add any real users here
const blacklistedUsernames = ["deepsource-autofix[bot]", "deepsource-io[bot]", "renovate[bot]", "dependabot[bot]"];
export const CodeContributorList = () => {
const [contributors, setContributors] = useState<Contributor[]>([]);
useEffect(() => {
fetch("/data/contributions.json").then(async (response) => {
const data = await response.json();
setContributors(data.filter((contributor: Contributor) => !blacklistedUsernames.includes(contributor.login)));
});
}, []);
return (
<div className={"flex flex-wrap gap-3 argos-ignore"}>
{contributors.map((contributor: Contributor) => (
<div className={"flex flex-col items-center w-24"}>
<img className={"w-24 h-24 aspect-square rounded mb-2"} src={contributor.avatar_url} alt={""} />
<h6 className={"truncate text-nowrap max-w-full"}>{contributor.login}</h6>
</div>
))}
</div>
);
};
@@ -0,0 +1,28 @@
import { useEffect, useState } from "react";
type Contributor = {
username: string;
avatarUrl: string;
};
export const TranslationContributorList = () => {
const [contributors, setContributors] = useState<Contributor[]>([]);
useEffect(() => {
fetch("/data/translation-contributions.json").then(async (response) => {
const data = await response.json();
setContributors(data);
});
}, []);
return (
<div className={"flex flex-wrap gap-3 argos-ignore"}>
{contributors.map((contributor: Contributor) => (
<div className={"flex flex-col items-center w-24"}>
<img className={"w-24 h-24 aspect-square rounded mb-2"} src={contributor.avatarUrl} alt={""} />
<h6 className={"truncate text-nowrap max-w-full"}>{contributor.username}</h6>
</div>
))}
</div>
);
};
@@ -0,0 +1,12 @@
import { ReactNode } from "react";
interface SectionContainerProps {
children: ReactNode;
className?: string;
}
export const SectionContainer = ({ children, className }: SectionContainerProps) => {
return (
<div className={"mx-auto w-full md:w-2/3 ps-10 pr-10 mb-5" + (className ? " " + className : "")}>{children}</div>
);
};
@@ -0,0 +1,147 @@
import React, { useRef } from "react";
import LeaderLine, { SocketType } from "leader-line-new";
export const DataflowVisualizationComponent = () => {
const homarrRef = useRef<HTMLImageElement>(null);
const sonarrRef = useRef<HTMLImageElement>(null);
const lidarrRef = useRef<HTMLImageElement>(null);
const radarrRef = useRef<HTMLImageElement>(null);
const jellyfinRef = useRef<HTMLImageElement>(null);
const plexRef = useRef<HTMLImageElement>(null);
const sabnzbdRef = useRef<HTMLImageElement>(null);
return (
<div className={"bg-black/[.10] py-20"}>
<h2 className={"text-center lg:text-5xl text-3xl font-extrabold mb-24"}>
No YAML configurations.
<br />
Easy and quick to manage integrations.
</h2>
<div className="relative max-w-128 h-80 mx-auto animated-dataflow mx-5">
<img
ref={homarrRef}
className={"absolute left-1/2 top-1/2 -translate-y-1/2 -translate-x-1/2 aspect-square object-contain"}
src={"/img/logo.png"}
alt={"Homarr Logo"}
width={100}
height={50}
/>
<img
ref={radarrRef}
className={"absolute left-0 top-0"}
src={"https://github.com/walkxcode/dashboard-icons/blob/main/png/radarr.png?raw=true"}
alt={"Radarr"}
width={50}
height={50}
/>
<img
ref={sonarrRef}
className={"absolute left-0 top-1/2 -translate-y-1/2"}
src={"https://github.com/walkxcode/dashboard-icons/blob/main/png/sonarr.png?raw=true"}
alt={"Sonarr"}
width={50}
height={50}
/>
<img
ref={lidarrRef}
className={"absolute left-0 bottom-0"}
src={"https://github.com/walkxcode/dashboard-icons/blob/main/png/lidarr.png?raw=true"}
alt={"Lidarr"}
width={50}
height={50}
/>
<img
ref={jellyfinRef}
className={"absolute right-0 top-1/2 -translate-y-1/2"}
src={"https://github.com/walkxcode/dashboard-icons/blob/main/png/jellyfin.png?raw=true"}
alt={"Jellyfin"}
width={50}
height={50}
/>
<img
ref={plexRef}
className={"absolute right-0 bottom-0"}
src={"https://github.com/walkxcode/dashboard-icons/blob/main/png/plex.png?raw=true"}
alt={"Plex"}
width={50}
height={50}
/>
<img
ref={sabnzbdRef}
className={"absolute right-0 top-0"}
src={"https://github.com/walkxcode/dashboard-icons/blob/main/png/sabnzbd.png?raw=true"}
alt={"Sabnzbd"}
width={50}
height={50}
/>
<LineTree start={sonarrRef} end={homarrRef} startSocket={"right"} endSocket={"left"} />
<LineTree start={radarrRef} end={homarrRef} startSocket={"right"} endSocket={"left"} />
<LineTree start={lidarrRef} end={homarrRef} startSocket={"right"} endSocket={"left"} />
<LineTree start={plexRef} end={homarrRef} startSocket={"left"} endSocket={"right"} x={100} />
<LineTree start={jellyfinRef} end={homarrRef} startSocket={"left"} endSocket={"right"} x={100} />
<LineTree start={sabnzbdRef} end={homarrRef} startSocket={"left"} endSocket={"right"} x={100} />
</div>
</div>
);
};
export const LineTree = ({
start,
end,
startSocket,
endSocket,
x = 0,
}: {
start: any;
end: any;
startSocket: SocketType;
endSocket: SocketType;
x?: number;
}) => {
const line = useRef<LeaderLine | null>(null);
let leaderLine: LeaderLine;
React.useEffect(() => {
const drawLine = () => {
leaderLine = new LeaderLine(start.current, LeaderLine.pointAnchor(end.current, { x: `${x}%` }), {
startSocket: startSocket,
endSocket: endSocket,
color: "var(--ifm-color-primary)",
size: 3,
dash: {
animation: true,
},
});
};
const timer = setInterval(() => {
if (start.current) {
clearInterval(timer);
drawLine();
}
}, 5);
return () => {
timer && clearInterval(timer);
leaderLine.remove();
};
}, []);
React.useEffect(() => {
// scroll and resize listeners could be assigned here
setTimeout(() => {
// skip current even loop and wait
// the end of parent's render call
if (line.current && end?.current) {
(line.current as any).position();
}
}, 0);
});
return null;
};
@@ -0,0 +1,26 @@
import videoLight from "./showcase-light.mp4";
import videoDark from "./showcase-dark.mp4";
import { IconClick } from "@tabler/icons-react";
import { SectionContainer } from "@site/src/components/pages/home/container/section-container";
import { useColorMode } from "@docusaurus/theme-common";
export const DragAndDropShowcase = () => {
const { colorMode } = useColorMode();
const video = colorMode === "dark" ? videoDark : videoLight;
return (
<div className={"my-24"}>
<SectionContainer className={"relative max-w-5xl"}>
<h2 className={"lg:text-5xl text-3xl font-extrabold text-center mb-12"}>Easy setup using drag and drop</h2>
<div className={"absolute top-0 right-0 translate-x-10 translate-y-8 hidden lg:block"}>
<IconClick size={120} />
</div>
<div
className={"rounded-3xl overflow-hidden border-8 border-red-500 border-solid"}
style={{ aspectRatio: "17.6/9" }}
>
<video className={"w-full"} src={video} autoPlay loop muted />
</div>
</SectionContainer>
</div>
);
};
@@ -0,0 +1,78 @@
import React from "react";
import {
IconAdjustments,
IconDragDrop,
IconIcons,
IconKey,
IconLanguage,
IconPlug,
TablerIcon,
} from "@tabler/icons-react";
interface Feature {
icon: TablerIcon;
title: string;
content: string;
}
const featureList: Feature[] = [
{
icon: IconDragDrop,
title: "Easy to use drag and drop system",
content:
"Using the drag and drop system, you can simply move parts of your dashboard using your mouse or your finger on mobile devices. No YAML / JSON configurations are involved.",
},
{
icon: IconIcons,
title: "Over 10K icons available",
content:
"We integrate with many different icon repositories to provide you with high quality and easy to use images.",
},
{
icon: IconPlug,
title: "Seamless integrations",
content:
"Integrate with your favourite applications to display their status or control them. Scales well with hundreds of users. Robust background job system enables high performance & scalability.",
},
{
icon: IconKey,
title: "Authentication & Authorization built in",
content: "Support for credentials authentication, OIDC and LDAP. Complex system to manage permissions for users",
},
{
icon: IconLanguage,
title: "26 languages available",
content: "Accessible for users in many countries thanks to the community translation program",
},
{
icon: IconAdjustments,
title: "Detailed settings for customization",
content: "Adjust apps and dashboards until you like them with helpful and easy to understand settings",
},
];
function FeatureComponent(props: Feature) {
return (
<div>
<div className={"flex flex-nowrap gap-4 items-center mb-2"}>
<div className={"rounded-xl bg-gray-100 dark:bg-zinc-800 p-3 pb-0.5 aspect-square"}>
<props.icon size={40} />
</div>
<div>
<h3 className="text-2xl font-extrabold text-gray-700 dark:text-gray-300 m-0">{props.title}</h3>
<p className="mb-0 text-base text-gray-500 dark:text-gray-400">{props.content}</p>
</div>
</div>
</div>
);
}
export default function HomepageFeatures() {
return (
<div className="mt-6 grid grid-cols-1 gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-2 xl:grid-cols-2 mb-32">
{featureList.map((props, idx) => (
<FeatureComponent key={idx} {...props} />
))}
</div>
);
}
@@ -0,0 +1,22 @@
import { AppWidget } from "./widgets/app-widget";
import { ClockWidget } from "./widgets/clock-widget";
import { DownloadsWidget } from "./widgets/downloads-widget";
import { WeatherWidget } from "./widgets/weather-widget";
import { StockWidget } from "./widgets/stock-widget";
import { EntityStateWidget } from "./widgets/entity-state-widget";
export const HeroCards = () => {
return (
<div className="argos-ignore hero-cards flex flex-wrap max-w-[504px] gap-y-4 gap-x-3 text-gray-700 dark:text-gray-300">
<StockWidget />
<AppWidget />
<AppWidget className="hidden 3xl:block" />
<WeatherWidget />
<DownloadsWidget />
<EntityStateWidget />
<AppWidget />
<ClockWidget />
</div>
);
};
@@ -0,0 +1,66 @@
import Link from "@docusaurus/Link";
import { IconDownload, IconExternalLink } from "@tabler/icons-react";
import clsx from "clsx";
import styles from "../../../../pages/index.module.css";
import { HeroCards } from "./hero-cards";
export default function HomeHero() {
return (
<header className={clsx("hero hero--primary", styles.heroBanner, styles.hero)}>
<div className="flex">
<div className="ps-0 lg:ps-52">
<h1 className="font-extrabold lg:text-7xl text-3xl">
A simple, yet
<br />
powerful dashboard
<br />
for your server.
</h1>
<p className={"lg:text-2xl text-base"}>
A sleek, modern dashboard that puts all of your apps and services at your fingertips. Control everything in
one convenient location. Seamlessly integrates with the apps you've added, providing you with valuable
information.
</p>
<div className={"flex flex-nowrap gap-2"}>
<Link
data-attr="Install button"
className={"button button--secondary button--lg rounded-3xl dark:border-zinc-600 dark:bg-zinc-800"}
to="/docs/getting-started"
>
<div className={"flex items-center gap-3"}>
<span className={"dark:text-gray-200"}>Install</span>
<IconDownload className={"dark:text-gray-200"} size={20} />
</div>
</Link>
<Link
data-attr="Try demo button"
className={"button button--secondary button--lg rounded-3xl dark:border-zinc-600 dark:bg-zinc-800"}
to="https://demo.homarr.dev/"
target="_blank"
rel="noopener noreferrer"
>
<div className={"flex items-center gap-3"}>
<span className={"dark:text-gray-200"}>Try Demo</span>
<IconExternalLink className={"dark:text-gray-200"} size={20} />
</div>
</Link>
<Link
data-attr="Redirect to PikaPods"
className={`button bg-neutral-900 button--lg rounded-3xl dark:border-zinc-600 dark:bg-zinc-800`}
to="https://www.pikapods.com/pods?run=homarr-v1"
>
<div className={"flex items-center gap-5"}>
<img src={"/img/pictures/partner/pika-pods.svg"} alt={"Pika Pods logo"} height={20} />
<span className={"dark:text-gray-200"}>Host from $2.3/month</span>
</div>
</Link>
</div>
</div>
<div className="flex-grow-1 w-full lg:block hidden hover-animation">
<HeroCards />
</div>
</div>
</header>
);
}
@@ -0,0 +1,20 @@
import { supportedIntegrations } from "@site/src/constants/supported-integrations";
import { CommonWidgetProps, WidgetCard } from "./card";
import { getRndInteger } from "@site/src/tools/math";
import clsx from "clsx";
export const AppWidget = ({ className }: CommonWidgetProps) => {
const randomApp = supportedIntegrations[getRndInteger(0, supportedIntegrations.length - 1)];
return (
<WidgetCard width={1} className={clsx("text-center", className)}>
<span className={"text-sm font-bold"}>{randomApp.name}</span>
<img
src={randomApp.iconUrl}
className="aspect-square scale-[0.6] transition-transform"
alt={`${randomApp.name} Icon`}
/>
<div className="absolute bottom-3 right-3 rounded-full bg-green-500 w-2 h-2"></div>
</WidgetCard>
);
};
@@ -0,0 +1,33 @@
import clsx from "clsx";
import { ReactNode } from "react";
export interface CommonWidgetProps {
className?: string;
}
interface WidgetCardProps extends CommonWidgetProps {
width: 1 | 2;
children: ReactNode;
onClick?: () => void;
}
export const WidgetCard = ({ width, children, className, onClick }: WidgetCardProps) => {
const Component = onClick ? "button" : "div";
return (
<Component
className={clsx(
"rounded-xl flex flex-col justify-center items-center p-2 relative border-none bg-[var(--ifm-navbar-background-color)]",
className,
onClick ? "cursor-pointer" : undefined,
)}
style={{
width: (128 * width + (width === 2 ? 16 : 0)) * 0.9,
height: 128,
background: "rgb(from var(--ifm-navbar-background-color) r g b / 80%)",
}}
onClick={onClick}
>
{children}
</Component>
);
};
@@ -0,0 +1,12 @@
import dayjs from "dayjs";
import { CommonWidgetProps, WidgetCard } from "./card";
import clsx from "clsx";
export const ClockWidget = ({ className }: CommonWidgetProps) => {
return (
<WidgetCard width={1} className={clsx("text-center gap-2", className)}>
<span className={"font-bold text-xl"}>{dayjs().format("HH:mm")}</span>
<span className="text-xs">{dayjs().format("dd, MMM. DD")}</span>
</WidgetCard>
);
};
@@ -0,0 +1,75 @@
import { getRndInteger } from "@site/src/tools/math";
import { useEffect, useState } from "react";
import { CommonWidgetProps, WidgetCard } from "./card";
import clsx from "clsx";
import { generateSlug } from "random-word-slugs";
export const DownloadsWidget = ({ className }: CommonWidgetProps) => {
const [downloads, setDownloads] = useState<Download[]>([]);
useEffect(() => {
const intervalId = setInterval(() => {
setDownloads((prev: Download[]) => {
const filteredPrev = prev.filter((download) => download.progress !== 100);
filteredPrev.forEach((download, index) => {
download.progress = Math.min(100, download.progress + getRndInteger(0, index < 2 ? 16 : 8));
});
if (filteredPrev.length < 3 && filteredPrev.length === prev.length) {
filteredPrev.push(generateRandomDownload());
}
return [...filteredPrev];
});
}, 500);
return () => {
clearInterval(intervalId);
};
}, []);
return (
<WidgetCard width={2} className={clsx("overflow-hidden !p-0", className)}>
<table className="mb-0 w-full h-full text-xs">
<thead className="inline-block w-full">
<tr className="inline-block w-full">
<th className="border-none px-2 py-1 text-start inline-block w-1/2">File</th>
<th className="border-none px-2 py-1 text-start inline-block w-1/2">Progress</th>
</tr>
</thead>
<tbody>
{downloads.map((file: Download, index: number) => {
return (
<tr key={index}>
<td className="border-none text-nowrap p-2">{file.filename}</td>
<td className="border-none p-2 w-full">
<div className="overflow-hidden rounded-xl h-2 w-full bg-zinc-600">
<div className="bg-green-600 h-full animated-width" style={{ width: `${file.progress}%` }}></div>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</WidgetCard>
);
};
interface Download {
filename: string;
progress: number;
}
const formats = ["mkv", "mp4"];
const generateRandomDownload = (): Download => {
const randomMovie = generateSlug(2);
const randomFormat = formats[getRndInteger(0, formats.length - 1)];
return {
filename: `${randomMovie}.${randomFormat}`,
progress: 0,
};
};
@@ -0,0 +1,18 @@
import { useColorMode } from "@docusaurus/theme-common";
import { CommonWidgetProps, WidgetCard } from "./card";
import clsx from "clsx";
export const EntityStateWidget = ({ className }: CommonWidgetProps) => {
const colorMode = useColorMode();
return (
<WidgetCard
width={1}
className={clsx("text-center", className)}
onClick={() => colorMode.setColorMode(colorMode.isDarkTheme ? "light" : "dark")}
>
<span className={"text-sm font-bold"}>Lights</span>
<span className="text-sm">{colorMode.isDarkTheme ? "OFF" : "ON"}</span>
</WidgetCard>
);
};
@@ -0,0 +1,76 @@
import { Line } from "@nivo/line";
import { IconTrendingDown, IconTrendingUp } from "@tabler/icons-react";
import clsx from "clsx";
import { useEffect, useState } from "react";
import { CommonWidgetProps, WidgetCard } from "./card";
export const StockWidget = ({ className }: CommonWidgetProps) => {
const [stockTrend, setStockTrend] = useState<{ x: number; y: number }[]>(generateStockTrend());
useEffect(() => {
const interval = setInterval(() => {
setStockTrend(generateStockTrend());
}, 15 * 1000);
return () => {
clearInterval(interval);
};
}, []);
const upwardTrend = stockTrend[0].y < stockTrend[stockTrend.length - 1].y;
return (
<WidgetCard width={2} className={clsx("!p-0", className)}>
<div className="absolute top-2 left-2 flex gap-1 font-bold items-center">
{upwardTrend && <IconTrendingUp size={20} color={"green"} />}
{!upwardTrend && <IconTrendingDown size={20} color={"red"} />}
HOMR
</div>
<Line
height={128}
width={244}
colors={[upwardTrend ? "green" : "red"]}
data={[
{
id: "stock",
data: stockTrend,
},
]}
curve={"natural"}
enablePoints={false}
enableGridX={false}
enableGridY={false}
yScale={{ type: "linear", min: "auto" }}
enableArea={true}
margin={{ bottom: 0, left: 4, right: 4, top: 40 }}
axisBottom={null}
axisLeft={null}
axisRight={null}
axisTop={null}
/>
<div className="absolute bottom-2 right-2 font-bold">{stockTrend[0].y}</div>
</WidgetCard>
);
};
const generateStockTrend = () => {
const data = [];
let y = 100; // Starting price
let trend = 1; // 1 for upward trend, -1 for downward trend
let trendDuration = Math.floor(Math.random() * 10) + 5; // Random trend duration between 5 and 15
for (let x = 0; x < 100; x++) {
if (trendDuration === 0) {
trend = Math.random() > 0.5 ? 1 : -1; // Randomly switch trend
trendDuration = Math.floor(Math.random() * 10) + 5;
}
let change = (Math.random() - 0.5) * 2; // Smaller fluctuations
y += (change + 0.5) * trend; // Apply trend bias
trendDuration--;
data.push({ x, y: parseFloat(y.toFixed(2)) });
}
return data;
};
@@ -0,0 +1,28 @@
import { IconCloud, IconWind, IconArrowUpRight, IconArrowDownRight } from "@tabler/icons-react";
import { CommonWidgetProps, WidgetCard } from "./card";
import clsx from "clsx";
export const WeatherWidget = ({ className }: CommonWidgetProps) => {
return (
<WidgetCard width={1} className={clsx("gap-2", className)}>
<div className="flex gap-2 justify-center items-center">
<IconCloud size={20} />
<span className="text-xl font-bold">10.8°C</span>
</div>
<div className="flex gap-2 justify-center items-center">
<IconWind size={14} />
<span className="text-sm">5 km/h</span>
</div>
<div className="flex gap-1 justify-center items-center">
<div className="flex justify-center items-center">
<IconArrowUpRight size={14} />
<span className="text-sm">13.5°C</span>
</div>
<div className="flex justify-center items-center">
<IconArrowDownRight size={14} />
<span className="text-sm">6.4°C</span>
</div>
</div>
</WidgetCard>
);
};
@@ -0,0 +1,20 @@
@keyframes scrolling {
0% {
transform: translateY(0);
}
100% {
transform: translateY(-50.8%);
}
}
.scrollAnimationContainer {
animation: scrolling;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@media (prefers-reduced-motion: reduce) {
.scrollAnimationContainer {
animation: none !important;
}
}
@@ -0,0 +1,68 @@
import { splitToNChunks } from "@site/src/tools/array";
import classes from "./available-integrations.module.css";
import { SectionContainer } from "@site/src/components/pages/home/container/section-container";
import { supportedIntegrations } from "@site/src/constants/supported-integrations";
const countIconGroups = 3;
const animationDurationInSeconds = 12;
export const AvailableIntegrations = () => {
const arrayInChunks = splitToNChunks(
supportedIntegrations.map((integration) => integration.iconUrl),
countIconGroups,
);
return (
<SectionContainer className={"my-24"}>
<div className={"md:h-80 w-full dark:bg-neutral-900 p-10 rounded-3xl overflow-hidden relative"}>
<div className={"flex gap-10 flex-nowrap"}>
<div className={"md:w-1/2 w-full"}>
<h2 className={"lg:text-5xl text-3xl font-extrabold "}>Many integrations built in</h2>
<p className={"text-xl text-gray-500 dark:text-gray-400"}>
Homarr has support for tons of your favourite applications, tools and websites. It integrates seamlessly
and tests proper connectivity and configuration for you. Using the tasks system, it scales efficiently
with tons of users, making Homarr reliable in big scale deployments too.
</p>
</div>
<div className={"rotate-12 w-1/2 hidden md:block argos-ignore"}>
<div className={"grid gap-12 grid-cols-3"}>
{Array(countIconGroups)
.fill(0)
.map((_, columnIndex) => (
<div key={`grid-column-${columnIndex}`} style={{ width: 50 }}>
<div
className={classes.scrollAnimationContainer}
style={{
animationDuration: `${animationDurationInSeconds - columnIndex}s`,
}}
>
{arrayInChunks[columnIndex]?.map((icon, index) => (
<img
className={"rounded"}
key={`grid-column-${columnIndex}-scroll-1-${index}`}
src={icon}
width={50}
height={50}
/>
))}
{/* This is used for making the animation seem seamless */}
{arrayInChunks[columnIndex]?.map((icon, index) => (
<img
className={"rounded"}
key={`grid-column-${columnIndex}-scroll-2-${index}`}
src={icon}
width={50}
height={50}
/>
))}
</div>
</div>
))}
</div>
</div>
</div>
</div>
</SectionContainer>
);
};
@@ -0,0 +1,109 @@
import React from "react";
const reviews = [
{
username: "Maeglin",
profilePicture: "/img/pictures/reviews/maeglin.webp",
fullText:
"Day 49/100 set up Homarr just for a test spin. It's a nice dashboard and the Arrs integration especialy the calendar is awesome.",
link: "https://twitter.com/Maeglin931/status/1555725442744586240",
},
{
username: "Noted",
profilePicture: "https://noted.lol/content/images/size/w256h256/2023/08/Icon-1.2-1.png",
fullText: "Homarr - A Simple, Self Hosted and Lightweight Server Homepage",
link: "https://noted.lol/homarr-a-simple-self-hosted-and-lightweight-server-homepage/",
},
{
username: "IBRACORP",
profilePicture: "/img/pictures/reviews/ibracorp.webp",
fullText: "Homarr Is Here To Stay. Here's Why | Selfhosted Homepage",
link: "https://www.youtube.com/watch?v=Mk9ZZiH5qi0",
},
{
username: "The Geek Freaks",
profilePicture:
"https://yt3.ggpht.com/6EWnfiBIE7Bus2MAS6zJsBARQTPWE7B3v7PXdwX1OfohaxylMqdQr-vt4j8gYZTJy2MBiOvu=s500-c-k-c0x00ffffff-no-rj",
fullText: "MEHR Durchblick mit eigenem Dashboard! (Unraid Homarr Tutorial) ",
link: "https://www.youtube.com/watch?v=DNGRL5QdBlg",
},
{
username: "u/uncmnsense",
profilePicture: "/img/pictures/reviews/r_selfhosted.webp",
fullText: "Anyone using homarr? check it out, its pretty fancy...",
link: "https://www.reddit.com/r/selfhosted/comments/wqxsk3/anyone_using_homarr_check_it_out_its_pretty_fancy",
},
{
username: "TechHut",
profilePicture:
"https://yt3.ggpht.com/TUoF-6QCUIKy6XgFtMG5FWi5FLVhtaUPtTOLvE7Ca3eJif1_RKBci07fKK-QvKxhC0HALEBH7Q=s500-c-k-c0x00ffffff-no-rj",
fullText: "my FAVORITE Home Server Dashboard - Homarr Setup in Docker",
link: "https://www.youtube.com/watch?v=A6vcTIzp_Ww",
},
{
username: "u/RoachedCoach",
profilePicture: "/img/pictures/reviews/r_unraid.webp",
fullText: "I enjoy Homarr - mostly because it's very simple, straightforward, and attractive.",
link: "https://www.reddit.com/r/unRAID/comments/wk3x2s/comment/ijn4vpg/",
},
{
username: "Mariushosting",
link: "https://mariushosting.com/synology-install-homarr-with-portainer/",
fullText:
"The thing I like most about Homarr is its speed and simplicity in adding links of your favorite docker apps. You can use it as a bookmark as well. Great integration with Plex, Sonarr, Radarr etc. Homarr supports multiple configs and persistent storage.",
profilePicture: "https://mariushosting.com/wp-content/uploads/2021/05/cropped-mariushosting512-192x192.png",
},
{
username: "u/YankeesIT",
profilePicture: "/img/pictures/reviews/r_selfhosted.webp",
fullText: "My Homarr setup...",
link: "https://www.reddit.com/r/selfhosted/comments/x84c9v/my_homarr_setup/",
},
{
username: "Selfhosted Corner",
profilePicture: "https://pbs.twimg.com/profile_banners/1674813613650120705/1691340056/600x200",
fullText:
"So you have many different #Selfhosted apps and you are having trouble organizing them? Fear not cause homarr is here is to save you the hassle.",
link: "https://twitter.com/SlhstdCorner/status/1698674826351685648",
},
];
export default function HomepageUserReviews() {
return (
<div className="my-24">
<h2 className="lg:text-5xl text-3xl font-extrabold text-center mb-10">Community</h2>
<p className={"text-xl text-gray-500 dark:text-gray-400 max-w-128 mx-auto mb-3"}>
We have a big and active community, which regularly contributes new features & integrations to Homarr. Our
community translation program and modularity end extendability in the code makes it easy to contribute and
extend Homarr.
</p>
<div className="mt-6 grid grid-cols-1 gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-2 xl:gap-x-8">
{reviews.map((review) => (
<article
key={review.link}
className="bg-gray-100 hover:bg-gray-200 dark:bg-zinc-800 dark:hover:bg-zinc-700 rounded-2xl"
>
<a
className="hover:no-underline h-full block p-5"
href={review.link}
target="_blank"
rel="noopener noreferrer"
data-attr="Open review"
>
<div className="flex space-x-4">
<img className="w-24 h-24 rounded-full dark:bg-zinc-900" src={review.profilePicture} alt="profile" />
<div className="space-y-1 font-medium dark:text-white">
<p className="text--bold m-0">{review.username}</p>
<p className="text-gray-600 dark:text-gray-400 text-sm m-0">{review.fullText}</p>
</div>
</div>
</a>
</article>
))}
</div>
</div>
);
}
@@ -0,0 +1,18 @@
.swiperText {
text-align: center;
font-size: 2.7rem;
margin-bottom: 50px;
}
.communityFeedbackPlaceholderImage {
display: block;
margin: 0 auto;
}
.communityFeedbackPlaceholderText {
text-align: center;
}
@@ -0,0 +1,55 @@
import type React from "react";
import CodeBlock from "@theme/CodeBlock";
import Admonition from "@theme/Admonition";
import { useEffect, useState } from "react";
const generateRandomHex = (length = 64): string => {
const array = new Uint8Array(length / 2);
crypto.getRandomValues(array);
return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join("");
};
export const DockerInstallSnippet: React.FC = () => {
const [randomHex, setRandomHex] = useState<string>("");
const generateNewHex = () => {
setRandomHex(generateRandomHex());
};
useEffect(() => {
generateNewHex();
}, []);
return (
<div>
<p>
First, create a <code>docker-compose.yml</code> file with the following content:
</p>
<CodeBlock language="yml" title="docker-compose.yml" className={"argos-ignore"}>
{`#---------------------------------------------------------------------#
# Homarr - A simple, yet powerful dashboard for your server. #
#---------------------------------------------------------------------#
services:
homarr:
container_name: homarr
image: ghcr.io/homarr-labs/homarr:latest
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock # Optional, only if you want docker integration
- ./homarr/appdata:/appdata
environment:
- SECRET_ENCRYPTION_KEY=${randomHex}
ports:
- '7575:7575'
`}
</CodeBlock>
<Admonition type="info">
Key provided above for the <code>SECRET_ENCRYPTION_KEY</code> environment variable is randomly generated using
your browser cryotography API. It will be different every time You can generate one yourself using{" "}
<code>openssl rand -hex 32</code>
<button className="px-1 mx-4" onClick={generateNewHex}>
Refresh key
</button>
</Admonition>
</div>
);
};
+13
View File
@@ -0,0 +1,13 @@
import { ReactNode } from "react";
interface BadgeProps {
children: ReactNode;
}
export const Badge = ({ children }: BadgeProps) => {
return (
<span className="inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap overflow-hidden border-transparent dark:bg-slate-700 bg-slate-200">
{children}
</span>
);
};
+29
View File
@@ -0,0 +1,29 @@
import { ReactNode } from "react";
import { Badge } from "../ui/badge";
interface DocsHeaderProps {
title: string;
description: string;
icon: ReactNode;
categories: string[];
}
export const DocsHeader = (props: DocsHeaderProps) => {
return (
<div className="flex gap-4 mt-4">
{props.icon}
<div>
<div className="flex gap-4 items-center">
<h1 className="!mb-0 text-4xl">{props.title}</h1>
<div className="flex gap-2">
{props.categories.map((category) => (
<Badge>{category}</Badge>
))}
</div>
</div>
<p>{props.description}</p>
</div>
</div>
);
};
@@ -0,0 +1,9 @@
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.
</p>
);
};
@@ -0,0 +1,87 @@
import { WidgetConfiguration, WidgetConfigurationItem } from "@site/src/types";
interface WidgetConfigProps {
configuration: WidgetConfiguration;
}
export const WidgetConfig = (props: WidgetConfigProps) => {
const innerConfigs = getInnerConfigs(props.configuration);
return (
<div>
<ConfigTable configuration={props.configuration} />
{innerConfigs.map((item) => (
<>
<h3 id={item.name}>{item.name}</h3>
<ConfigTable configuration={item.configuration} />
</>
))}
</div>
);
};
const ConfigTable = (props: WidgetConfigProps) => {
return (
<table>
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Values</th>
<th>Default value</th>
</tr>
</thead>
<tbody>
{props.configuration.items.map((item, index) => (
<tr key={index}>
<td>{item.name}</td>
<td>{item.description}</td>
<td>
<ConfigValues values={item.values} />
</td>
<td>{item.defaultValue}</td>
</tr>
))}
</tbody>
</table>
);
};
const ConfigValues = ({ values }: Pick<WidgetConfigurationItem, "values">) => {
if (typeof values === "string") return <span>{values}</span>;
if (values.type === "boolean") return <span>yes / no</span>;
if (values.type === "string") return <span>String</span>;
if (values.type === "select") {
return (
<ul>
{values.options.map((option) => (
<li key={option}>{option}</li>
))}
</ul>
);
}
const reference = <a href={`#${values.name}`}>{values.name}</a>;
if (values.type === "object") return reference;
if (values.type === "array") return <span>{reference}[]</span>;
};
const getInnerConfigs = (config: WidgetConfiguration) => {
const configItems: Extract<WidgetConfigurationItem["values"], { type: "object" | "array" }>[] = [];
config.items.forEach((item) => {
if (
item.values &&
typeof item.values === "object" &&
(item.values.type === "object" || item.values.type === "array")
) {
configItems.push(item.values);
const innerItems = getInnerConfigs(item.values.configuration);
if (innerItems.length > 0) {
configItems.push(...innerItems);
}
}
});
return configItems;
};
@@ -0,0 +1,22 @@
import { DocsHeader } from "../ui/header";
import { WidgetDefinition } from "@site/src/types";
interface WidgetHeaderProps {
widget: WidgetDefinition;
categories: string[];
}
export const WidgetHeader = (props: WidgetHeaderProps) => {
return (
<DocsHeader
title={props.widget.name}
description={props.widget.description}
icon={
<div className="w-12 h-12 flex justify-center items-center bg-red-500 bg-opacity-10 rounded-md">
<props.widget.icon size={32} stroke={1.5} className="stroke-red-500" />
</div>
}
categories={props.categories}
/>
);
};
@@ -0,0 +1,52 @@
import { useColorMode } from "@docusaurus/theme-common";
import { IntegrationDefinition } from "@site/src/types";
import { IconExternalLink } from "@tabler/icons-react";
import { getIntegrationIconUrl } from "../integrations/header";
interface WidgetIntegrationsProps {
items: {
integration: IntegrationDefinition;
note?: string;
}[];
}
export const WidgetIntegrations = ({ items }: WidgetIntegrationsProps) => {
const { isDarkTheme } = useColorMode();
return (
<div className="flex flex-col gap-4 mt-4 w-full">
<div className="flex flex-col gap-2">
{items.map((item) => (
<div
key={item.integration.name}
className="flex gap-6 rounded-xl border border-solid dark:border-[#333] border-[#e5e7eb] p-4 shadow-sm w-full items-center justify-between"
>
<div className="flex gap-6 items-center">
<img
width={40}
height={40}
src={getIntegrationIconUrl(item.integration, isDarkTheme)}
alt={`${item.integration.name} icon`}
className="w-10 h-10"
/>
<div className="flex flex-col gap-0">
<span className="text-base font-bold">{item.integration.name}</span>
<span className="text-sm dark:text-[#999999] text-[#696969]">{item.integration.description}</span>
{item.note && <span className="text-xs text-yellow-500 mt-1">{item.note}</span>}
</div>
</div>
<a
href={item.integration.path}
className="border border-solid border-[#e5e7eb] dark:border-[#333] p-2 py-1 rounded-md gap-2 flex justify-center items-center hover:no-underline hover:bg-slate-100 dark:hover:bg-gray-800"
>
<IconExternalLink size={16} stroke={1.5} className="dark:stroke-white stroke-black" />
<span className="dark:text-white text-black font-medium text-sm">Details</span>
</a>
</div>
))}
</div>
</div>
);
};