feat(docs): migrate documentation site into monorepo (#5912)
This commit is contained in:
1 parent
3e464a76f2
commit
9e421c67bd
605 files changed
+26764
-219
No files matched your search
@@ -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>
|
||||
);
|
||||
};
|
||||
+28
@@ -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>
|
||||
);
|
||||
};
|
||||
Binary file not shown.
Binary file not shown.
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user