feat!: release v2 (#6545)

Co-authored-by: Artur <145776561+arturict@users.noreply.github.com>
Co-authored-by: arturict <arturict@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Co-authored-by: ajnart <thomas@ajnart.fr>
Co-authored-by: ajnart <thomas@ajnart.dev>
Co-authored-by: chenjun <chenjun@foxmail.com>
Co-authored-by: Wikibear <wikibear@users.noreply.github.com>
Co-authored-by: Bergasha <bergasha@gmail.com>
Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com>
Co-authored-by: Lucas Araujo <lucas.lra@gmail.com>
Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com>
Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social>
Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com>
Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com>
Co-authored-by: thrawnx <tight.bulb9384@fastmail.com>
Co-authored-by: Chris <58918656+nordada@users.noreply.github.com>
Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com>
Co-authored-by: Yanis <naamaneyanis@gmail.com>
Co-authored-by: Wikibear <me@sej.de>
Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
This commit is contained in:
3286 files changed
+284386 -139176

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
"use client";
import { useEffect } from "react";
import { initAnalytics, track } from "@/lib/analytics";
export function Analytics() {
useEffect(() => {
initAnalytics();
function onClick(event: MouseEvent) {
if (event.button !== 0 && event.button !== 1) return;
if (!(event.target instanceof Element)) return;
const link = event.target.closest<HTMLAnchorElement>('a[data-attr="Install button"][href]');
if (!link || link.closest(".homarr-carbon")) return;
const url = new URL(link.href, window.location.origin);
if (url.protocol !== "http:" && url.protocol !== "https:") return;
track("Installation Opened", { destination: `${url.origin}${url.pathname}` });
}
document.addEventListener("click", onClick);
document.addEventListener("auxclick", onClick);
return () => {
document.removeEventListener("click", onClick);
document.removeEventListener("auxclick", onClick);
};
}, []);
return null;
}
@@ -0,0 +1,31 @@
"use client";
import { IconList } from "@tabler/icons-react";
import type { TOCItemType } from "fumadocs-core/toc";
import { TOCItem, TOCItems } from "fumadocs-ui/components/toc/default";
import { TOCProvider, TOCScrollArea } from "fumadocs-ui/components/toc";
export function BlogToc({ items }: { items: TOCItemType[] }) {
if (items.length === 0) return null;
return (
<TOCProvider toc={items}>
<aside
className="sticky top-24 hidden max-h-[calc(100dvh-8rem)] min-w-0 self-start xl:block"
aria-labelledby="blog-toc-title"
>
<h2 id="blog-toc-title" className="flex items-center gap-2 text-sm font-medium text-fd-muted-foreground">
<IconList aria-hidden size={16} />
On this page
</h2>
<TOCScrollArea className="mt-3 max-h-[calc(100dvh-11rem)]">
<TOCItems>
{items.map((item) => (
<TOCItem key={item.url} item={item} />
))}
</TOCItems>
</TOCScrollArea>
</aside>
</TOCProvider>
);
}
+65 -158
View File
@@ -1,170 +1,77 @@
import { useLocation } from "@docusaurus/router";
import React, { useEffect } from "react";
"use client";
export function Carbon() {
const ref = React.useRef<HTMLDivElement>(null!);
const location = useLocation();
if (process.env.NODE_ENV === "development") {
return null;
}
import { usePathname } from "next/navigation";
import { useEffect, useRef } from "react";
type Placement = "toc" | "inline" | "banner" | "site-footer";
/** Responsive placements are mutually exclusive: never request a hidden ad. */
export function Carbon({ placement = "banner" }: { placement?: Placement }) {
const ref = useRef<HTMLDivElement>(null);
const pathname = usePathname();
const hidden =
pathname === "/" || (placement === "site-footer" && /^\/workshop\/(?!admin\/?$)[^/]+\/?$/.test(pathname ?? ""));
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]);
const host = ref.current;
if (!host || hidden) return;
if (process.env.NODE_ENV !== "production" && process.env.NEXT_PUBLIC_ENABLE_CARBON_ADS !== "true") return;
const desktop = window.matchMedia("(min-width: 1280px)");
return (
<>
<div ref={ref} data-visual-test="blackout" className="flex flex-col m-4 space-y-2 argos-ignore" />
</>
);
}
let pendingLoad: ReturnType<typeof setTimeout> | undefined;
// Carbon responses can outlive the script that requested them. Keep the newest
// creative if an earlier navigation or breakpoint request finishes late.
const observer = new MutationObserver(() => {
const creatives = host.querySelectorAll('[id="carbonads"]');
for (const creative of Array.from(creatives).slice(0, -1)) creative.remove();
});
observer.observe(host, { childList: true, subtree: true });
// 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));
function update() {
clearTimeout(pendingLoad);
if (!host) return;
const visible =
placement === "banner" ||
placement === "site-footer" ||
(placement === "toc" && desktop.matches) ||
(placement === "inline" && !desktop.matches);
if (!visible) {
host.replaceChildren();
return;
}
if (host.querySelector("script")) return;
// Defer until after effect cleanup, avoiding duplicate requests during
// React Strict Mode's setup/cleanup/setup cycle.
pendingLoad = setTimeout(() => {
if (!host.isConnected || host.querySelector("script")) return;
const script = document.createElement("script");
script.id = "_carbonads_js";
script.async = true;
script.src = "https://cdn.carbonads.com/carbon.js?serve=CWBDTKQM&placement=homarrdev&format=cover";
host.appendChild(script);
}, 0);
}
update();
desktop.addEventListener("change", update);
return () => {
clearTimeout(pendingLoad);
observer.disconnect();
desktop.removeEventListener("change", update);
host.replaceChildren();
};
}, [pathname, placement, hidden]);
const observer = new MutationObserver(() => trim());
observer.observe(document.body, { childList: true, subtree: true });
// Initial pass
trim();
return () => observer.disconnect();
}, [location]);
if (hidden) return null;
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"
/>
</>
<aside
aria-label="Advertisement"
className={`homarr-carbon homarr-carbon-${placement}`}
data-visual-test="blackout"
>
<div ref={ref} />
</aside>
);
}
@@ -0,0 +1,451 @@
.explorer {
--catalog-border: color-mix(in srgb, var(--color-fd-border) 42%, transparent);
--catalog-surface: color-mix(in srgb, var(--color-fd-card) 90%, var(--color-fd-primary) 10%);
border: 1px solid var(--catalog-border);
border-radius: 18px;
margin: 1.5rem 0 2rem;
overflow: hidden;
}
.header {
align-items: flex-start;
background: linear-gradient(135deg, color-mix(in srgb, var(--color-fd-primary) 13%, transparent), transparent 65%);
display: flex;
gap: 1.5rem;
justify-content: space-between;
padding: 1.4rem;
}
.eyebrow {
color: var(--color-fd-primary);
font-size: 0.72rem;
font-weight: 750;
letter-spacing: 0.09em;
margin: 0 0 0.35rem;
text-transform: uppercase;
}
.title {
font-size: clamp(1.25rem, 3vw, 1.65rem);
margin: 0;
}
.summary {
color: var(--color-fd-muted-foreground);
margin: 0.45rem 0 0;
max-width: 68ch;
}
.rawLink,
.expandButton,
.showMoreButton,
.resetButton {
align-items: center;
border-radius: 9px;
cursor: pointer;
display: inline-flex;
font: inherit;
font-weight: 650;
justify-content: center;
}
.rawLink,
.expandButton,
.showMoreButton {
background: var(--color-fd-primary);
border: 1px solid var(--color-fd-primary);
color: var(--color-fd-primary-foreground);
padding: 0.58rem 0.85rem;
text-decoration: none;
}
.rawLink {
flex: 0 0 auto;
white-space: nowrap;
}
.rawLink:hover,
.expandButton:hover,
.showMoreButton:hover {
background: var(--color-fd-primary);
color: var(--color-fd-primary-foreground);
text-decoration: none;
}
.versions {
border-bottom: 1px solid var(--catalog-border);
border-top: 1px solid var(--catalog-border);
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
margin: 0;
}
.globalProps {
align-items: center;
border-bottom: 1px solid var(--catalog-border);
display: grid;
gap: 1rem;
grid-template-columns: minmax(0, 1fr) auto;
padding: 1rem 1.4rem;
scroll-margin-top: calc(4.25rem + 1rem);
}
.globalProps h4,
.globalProps p {
margin: 0;
}
.globalProps p {
color: var(--color-fd-muted-foreground);
font-size: 0.86rem;
margin-top: 0.2rem;
}
.globalPropsTable {
grid-column: 1 / -1;
}
.versions div {
padding: 0.75rem 1.4rem;
}
.versions div + div {
border-left: 1px solid var(--catalog-border);
}
.versions dt {
color: var(--color-fd-muted-foreground);
font-size: 0.72rem;
font-weight: 650;
text-transform: uppercase;
}
.versions dd {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.86rem;
margin: 0.12rem 0 0;
}
.filters {
align-items: end;
background: var(--catalog-surface);
display: grid;
gap: 0.8rem;
grid-template-columns: minmax(220px, 2fr) repeat(3, minmax(130px, 1fr));
padding: 1.15rem 1.4rem;
}
.filters label:not(.checkboxField) {
display: grid;
gap: 0.3rem;
}
.filters label > span {
color: var(--color-fd-muted-foreground);
font-size: 0.76rem;
font-weight: 700;
}
.filters input[type="search"],
.filters select {
background: var(--color-fd-background);
border: 1px solid var(--catalog-border);
border-radius: 9px;
color: var(--color-fd-foreground);
font: inherit;
min-height: 42px;
padding: 0.55rem 0.68rem;
width: 100%;
}
.filters input:focus-visible,
.filters select:focus-visible,
.expandButton:focus-visible,
.showMoreButton:focus-visible,
.resetButton:focus-visible,
.anchorLink:focus-visible {
outline: 3px solid color-mix(in srgb, var(--color-fd-primary) 45%, transparent);
outline-offset: 2px;
}
.checkboxField {
align-items: center;
display: flex;
gap: 0.5rem;
grid-column: 1 / -1;
}
.checkboxField input {
height: 1rem;
margin: 0;
width: 1rem;
}
.resultSummary {
align-items: center;
border-bottom: 1px solid var(--catalog-border);
display: flex;
gap: 0.6rem;
min-height: 48px;
padding: 0.7rem 1.4rem;
}
.resetButton {
background: transparent;
border: 0;
color: var(--color-fd-primary);
margin-left: auto;
padding: 0.35rem;
}
.componentList {
display: grid;
}
.componentCard {
scroll-margin-top: calc(4.25rem + 1rem);
}
.componentCard + .componentCard {
border-top: 1px solid var(--catalog-border);
}
.componentHeading {
align-items: center;
display: flex;
gap: 1rem;
justify-content: space-between;
padding: 1rem 1.4rem;
}
.componentNameRow {
align-items: baseline;
display: flex;
gap: 0.45rem;
}
.componentNameRow h4 {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 1.05rem;
margin: 0;
}
.anchorLink {
font-weight: 700;
opacity: 0;
text-decoration: none;
}
.componentCard:hover .anchorLink,
.anchorLink:focus-visible {
opacity: 1;
}
.badges {
display: flex;
flex-wrap: wrap;
gap: 0.38rem;
margin-top: 0.45rem;
}
.badges span {
background: var(--color-fd-muted);
border-radius: 999px;
color: var(--color-fd-foreground);
font-size: 0.7rem;
padding: 0.2rem 0.48rem;
}
.badges span[data-safety="allowed"] {
background: color-mix(in srgb, #2f9e44 18%, transparent);
color: color-mix(in srgb, #2f9e44 78%, var(--color-fd-foreground));
}
.badges span[data-safety="wrapped"] {
background: color-mix(in srgb, var(--color-fd-primary) 18%, transparent);
color: var(--color-fd-primary);
}
.badges span[data-safety="denied"] {
background: color-mix(in srgb, #e03131 18%, transparent);
color: color-mix(in srgb, #e03131 78%, var(--color-fd-foreground));
}
.expandButton {
background: transparent;
color: var(--color-fd-primary);
flex: 0 0 auto;
padding: 0.42rem 0.7rem;
}
.expandButton:hover {
background: color-mix(in srgb, var(--color-fd-primary) 12%, transparent);
color: var(--color-fd-primary);
}
.description,
.deniedReason {
color: var(--color-fd-muted-foreground);
margin: -0.45rem 1.4rem 1rem;
}
.deniedReason {
color: #c92a2a;
}
.details {
background: var(--catalog-surface);
border-top: 1px solid var(--catalog-border);
padding: 1.25rem 1.4rem;
}
.details h5 {
font-size: 0.9rem;
margin: 0 0 0.6rem;
}
.detailsGrid {
display: grid;
gap: 1rem;
grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
margin-bottom: 1.2rem;
}
.detailsGrid > section {
background: var(--color-fd-background);
border: 1px solid var(--catalog-border);
border-radius: 12px;
min-width: 0;
padding: 0.9rem;
}
.detailsGrid p:last-of-type {
margin-bottom: 0.6rem;
}
.a11yList {
font-size: 0.86rem;
margin: 0 0 0.7rem;
padding-left: 1.1rem;
}
.tableScroll {
border: 1px solid var(--catalog-border);
border-radius: 10px;
max-height: 480px;
overflow: auto;
}
.propsTable {
display: table;
font-size: 0.78rem;
margin: 0;
width: 100%;
}
.propsTable th {
background: var(--color-fd-background);
position: sticky;
top: 0;
z-index: 1;
}
.propsTable th,
.propsTable td {
min-width: 96px;
padding: 0.58rem;
vertical-align: top;
}
.propsTable td:nth-child(2) {
max-width: 340px;
min-width: 190px;
}
.type {
overflow-wrap: anywhere;
white-space: normal;
}
.muted {
color: var(--color-fd-muted-foreground);
}
.rawComponent {
margin-top: 1rem;
}
.rawComponent summary {
color: var(--color-fd-primary);
cursor: pointer;
font-weight: 650;
}
.rawComponent > div {
margin-top: 0.6rem;
}
.showMoreButton {
display: flex;
margin: 1.1rem auto;
}
.loading,
.error,
.empty {
background: var(--color-fd-card);
border: 1px solid var(--color-fd-border);
border-radius: 12px;
margin: 1rem 0;
padding: 1rem;
}
.empty {
border: 0;
margin: 0;
text-align: center;
}
.error {
border-color: color-mix(in srgb, #e03131 58%, transparent);
}
@media (max-width: 996px) {
.filters {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.searchField {
grid-column: 1 / -1;
}
}
@media (max-width: 650px) {
.header,
.componentHeading {
align-items: stretch;
flex-direction: column;
}
.rawLink,
.expandButton {
align-self: flex-start;
}
.versions,
.filters,
.detailsGrid,
.globalProps {
grid-template-columns: 1fr;
}
.versions div + div {
border-left: 0;
border-top: 1px solid var(--catalog-border);
}
.searchField,
.checkboxField {
grid-column: auto;
}
}
@@ -0,0 +1,495 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { buildCustomJsxComponentUsageExample } from "@homarr/custom-widgets/catalog-example";
import type {
CustomJsxAuthoringCatalog,
CustomJsxComponentApi,
CustomJsxPropDescriptor,
} from "@homarr/custom-widgets/catalog";
import { CustomWidgetCodeExample } from "./custom-widget-code";
import styles from "./custom-jsx-component-reference.module.css";
type ComponentCatalog = CustomJsxAuthoringCatalog;
type CatalogComponent = CustomJsxComponentApi;
type CatalogProp = CustomJsxPropDescriptor;
const PAGE_SIZE = 40;
export function CustomJsxComponentReference() {
return <CustomJsxComponentReferenceClient />;
}
function ComponentReferenceFallback() {
return (
<div className={styles.loading} aria-busy="true">
Loading the Custom JSX component catalog…
</div>
);
}
function CustomJsxComponentReferenceClient() {
const catalogUrl = "/custom-widgets/component-catalog-v1.json";
const [catalog, setCatalog] = useState<ComponentCatalog | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [packageFilter, setPackageFilter] = useState("all");
const [categoryFilter, setCategoryFilter] = useState("all");
const [safetyFilter, setSafetyFilter] = useState("available");
const [bindableOnly, setBindableOnly] = useState(false);
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const [expanded, setExpanded] = useState<ReadonlySet<string>>(() => new Set());
const [showGlobalProps, setShowGlobalProps] = useState(false);
useEffect(() => {
const controller = new AbortController();
setLoadError(null);
void fetch(catalogUrl, { signal: controller.signal })
.then(async (response) => {
if (!response.ok) throw new Error(`The catalog request returned HTTP ${response.status}.`);
return (await response.json()) as ComponentCatalog;
})
.then((value) => {
if (!Array.isArray(value.components) || !Array.isArray(value.types) || !Array.isArray(value.globalProps)) {
throw new Error("The catalog response has an unsupported shape.");
}
setCatalog(value);
})
.catch((error: unknown) => {
if (controller.signal.aborted) return;
setLoadError(error instanceof Error ? error.message : "The component catalog could not be loaded.");
});
return () => controller.abort();
}, [catalogUrl]);
const categories = useMemo(
() => [...new Set(catalog?.components.map((component) => component.category) ?? [])].sort(),
[catalog],
);
const packages = useMemo(
() => [...new Set(catalog?.components.map((component) => component.package) ?? [])].sort(),
[catalog],
);
const filteredComponents = useMemo(() => {
if (!catalog) return [];
const normalizedQuery = query.trim().toLocaleLowerCase();
return catalog.components.filter((component) => {
if (packageFilter !== "all" && component.package !== packageFilter) return false;
if (categoryFilter !== "all" && component.category !== categoryFilter) return false;
if (safetyFilter === "available" && component.safety === "denied") return false;
if (safetyFilter !== "all" && safetyFilter !== "available" && component.safety !== safetyFilter) return false;
if (bindableOnly && !component.bind) return false;
if (!normalizedQuery) return true;
return [
component.name,
component.package,
component.category,
component.description,
component.deniedReason,
...component.subcomponents,
...component.props.map((prop) => prop.name),
].some((value) => value?.toLocaleLowerCase().includes(normalizedQuery));
});
}, [bindableOnly, catalog, categoryFilter, packageFilter, query, safetyFilter]);
const visibleComponents = filteredComponents.slice(0, visibleCount);
useEffect(() => {
setVisibleCount(PAGE_SIZE);
}, [bindableOnly, categoryFilter, packageFilter, query, safetyFilter]);
useEffect(() => {
if (!catalog) return;
const revealHashTarget = () => {
const hash = decodeLocationHash(window.location.hash);
const component = catalog.components.find((entry) => componentAnchor(entry.name) === hash);
if (!component) return;
if (!document.getElementById(hash)) {
setQuery(component.name);
setPackageFilter("all");
setCategoryFilter("all");
setSafetyFilter(component.safety === "denied" ? "all" : "available");
setBindableOnly(false);
setVisibleCount(PAGE_SIZE);
}
setExpanded((current) => new Set([...current, component.name]));
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => document.getElementById(hash)?.scrollIntoView({ block: "start" }));
});
};
revealHashTarget();
window.addEventListener("hashchange", revealHashTarget);
return () => window.removeEventListener("hashchange", revealHashTarget);
}, [catalog]);
if (loadError) {
return (
<div className={styles.error} role="alert">
<strong>Component catalog unavailable.</strong> {loadError} <a href={catalogUrl}>Open the raw catalog</a> to
inspect the generated data directly.
</div>
);
}
if (!catalog) return <ComponentReferenceFallback />;
return (
<section className={styles.explorer} aria-label="Custom JSX component catalog">
<header className={styles.header}>
<div>
<p className={styles.eyebrow}>Release-matched static catalog</p>
<h3 className={styles.title}>Explore {catalog.components.length} Custom JSX components</h3>
<p className={styles.summary}>
This browser reads the same versioned JSON artifact used by offline authoring. It never imports or executes
the listed component objects.
</p>
</div>
<a className={styles.rawLink} href={catalogUrl} target="_blank" rel="noreferrer">
Open raw JSON
</a>
</header>
<dl className={styles.versions}>
<div>
<dt>Catalog schema</dt>
<dd>v{catalog.schemaVersion}</dd>
</div>
<div>
<dt>Custom Widget</dt>
<dd>{catalog.customWidgetVersion}</dd>
</div>
<div>
<dt>Mantine</dt>
<dd>{catalog.mantineVersion}</dd>
</div>
<div>
<dt>Blocked capabilities</dt>
<dd>{catalog.blockedCapabilities.length}</dd>
</div>
</dl>
<section className={styles.globalProps} id="custom-jsx-global-props">
<div>
<h4>Shared serializable props</h4>
<p>
Every available component also receives these {catalog.globalProps.length} generated global, ARIA, data, and
safe style props. Component cards below list only their component-specific additions.
</p>
</div>
<button
type="button"
className={styles.expandButton}
aria-expanded={showGlobalProps}
aria-controls="custom-jsx-global-props-table"
onClick={() => setShowGlobalProps((current) => !current)}
>
{showGlobalProps ? "Hide global props" : "View global props"}
</button>
{showGlobalProps && (
<div className={styles.globalPropsTable} id="custom-jsx-global-props-table">
<PropTable props={catalog.globalProps} types={catalog.types} />
</div>
)}
</section>
<div className={styles.filters}>
<label className={styles.searchField}>
<span>Search names, props, and subcomponents</span>
<input
type="search"
value={query}
onChange={(event) => setQuery(event.currentTarget.value)}
placeholder="For example: TextInput, bind, chart…"
/>
</label>
<label>
<span>Package</span>
<select value={packageFilter} onChange={(event) => setPackageFilter(event.currentTarget.value)}>
<option value="all">All packages</option>
{packages.map((packageName) => (
<option key={packageName} value={packageName}>
{packageName}
</option>
))}
</select>
</label>
<label>
<span>Category</span>
<select value={categoryFilter} onChange={(event) => setCategoryFilter(event.currentTarget.value)}>
<option value="all">All categories</option>
{categories.map((category) => (
<option key={category} value={category}>
{humanize(category)}
</option>
))}
</select>
</label>
<label>
<span>Availability</span>
<select value={safetyFilter} onChange={(event) => setSafetyFilter(event.currentTarget.value)}>
<option value="available">Available only</option>
<option value="all">Available and denied</option>
<option value="allowed">Allowed only</option>
<option value="wrapped">Wrapped only</option>
<option value="denied">Denied only</option>
</select>
</label>
<label className={styles.checkboxField}>
<input
type="checkbox"
checked={bindableOnly}
onChange={(event) => setBindableOnly(event.currentTarget.checked)}
/>
Supports temporary <code>bind</code>
</label>
</div>
<div className={styles.resultSummary} aria-live="polite">
<strong>{filteredComponents.length}</strong> matching component{filteredComponents.length === 1 ? "" : "s"}
{(query ||
packageFilter !== "all" ||
categoryFilter !== "all" ||
safetyFilter !== "available" ||
bindableOnly) && (
<button
type="button"
className={styles.resetButton}
onClick={() => {
setQuery("");
setPackageFilter("all");
setCategoryFilter("all");
setSafetyFilter("available");
setBindableOnly(false);
}}
>
Reset filters
</button>
)}
</div>
{filteredComponents.length === 0 ? (
<div className={styles.empty}>No component matches these filters.</div>
) : (
<div className={styles.componentList}>
{visibleComponents.map((component) => {
const isExpanded = expanded.has(component.name);
return (
<article className={styles.componentCard} id={componentAnchor(component.name)} key={component.name}>
<div className={styles.componentHeading}>
<div>
<div className={styles.componentNameRow}>
<h4>{component.name}</h4>
<a
className={styles.anchorLink}
href={`#${componentAnchor(component.name)}`}
aria-label={`Link to ${component.name}`}
>
#
</a>
</div>
<div className={styles.badges}>
<span>{component.package}</span>
<span>{humanize(component.category)}</span>
<span data-safety={component.safety}>{component.safety}</span>
{component.bind && <span>bind: {component.bind.type}</span>}
</div>
</div>
<button
type="button"
className={styles.expandButton}
aria-expanded={isExpanded}
aria-controls={`${componentAnchor(component.name)}-details`}
onClick={() => {
setExpanded((current) => {
const next = new Set(current);
if (next.has(component.name)) next.delete(component.name);
else next.add(component.name);
return next;
});
}}
>
{isExpanded ? "Hide API" : "View API"}
</button>
</div>
{(component.description || component.deniedReason) && (
<p className={component.safety === "denied" ? styles.deniedReason : styles.description}>
{component.deniedReason ?? component.description}
</p>
)}
{isExpanded && (
<ComponentDetails
id={`${componentAnchor(component.name)}-details`}
component={component}
types={catalog.types}
/>
)}
</article>
);
})}
</div>
)}
{visibleCount < filteredComponents.length && (
<button
type="button"
className={styles.showMoreButton}
onClick={() => setVisibleCount((current) => current + PAGE_SIZE)}
>
Show {Math.min(PAGE_SIZE, filteredComponents.length - visibleCount)} more
</button>
)}
</section>
);
}
function decodeLocationHash(hash: string) {
const value = hash.slice(1);
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function ComponentDetails({ id, component, types }: { id: string; component: CatalogComponent; types: string[] }) {
return (
<div className={styles.details} id={id}>
<div className={styles.detailsGrid}>
<section>
<CustomWidgetCodeExample
id={`${componentAnchor(component.name)}-usage`}
label="Usage example"
language="jsx"
code={buildCustomJsxComponentUsageExample(component, { types })}
height="180px"
/>
</section>
<section>
<h5>Component contract</h5>
{component.bind ? (
<p>
<code>bind</code> stores a temporary <code>{component.bind.type}</code> input. Initialize it with{" "}
<code>{component.bind.initialProp}</code>.
</p>
) : (
<p>This component does not expose a temporary-input adapter.</p>
)}
{component.subcomponents.length > 0 && (
<p>
<strong>Subcomponents:</strong>{" "}
{component.subcomponents.map((name, index) => (
<span key={name}>
{index > 0 && ", "}
<code>{name}</code>
</span>
))}
</p>
)}
{component.blockedProps.length > 0 && (
<ul className={styles.a11yList}>
{component.blockedProps.map((prop) => (
<li key={prop.name}>
<code>{prop.name}</code> is blocked: {prop.reason}
</li>
))}
</ul>
)}
{component.accessibilityRequirements.length > 0 && (
<ul className={styles.a11yList}>
{component.accessibilityRequirements.map((requirement) => (
<li key={requirement}>{requirement}</li>
))}
</ul>
)}
<a href={component.documentationUrl} target="_blank" rel="noreferrer">
Open upstream documentation
</a>
</section>
</div>
<section>
<h5>
Component props <span className={styles.muted}>({component.props.length})</span>
</h5>
{component.props.length === 0 ? (
<p>No component-specific props are exposed.</p>
) : (
<PropTable props={component.props} types={types} />
)}
</section>
<details className={styles.rawComponent}>
<summary>Raw component JSON</summary>
<CustomWidgetCodeExample
id={`${componentAnchor(component.name)}-json`}
label="Component JSON"
code={JSON.stringify(component, null, 2)}
height="320px"
/>
</details>
</div>
);
}
function PropTable({ props, types }: { props: CatalogProp[]; types: string[] }) {
return (
<div className={styles.tableScroll}>
<table className={styles.propsTable}>
<thead>
<tr>
<th>Prop</th>
<th>Type</th>
<th>Required</th>
<th>Known values</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{props.map((prop) => (
<tr key={prop.name}>
<td>
<code>{prop.name}</code>
</td>
<td>
<code className={styles.type}>{types[prop.typeRef] ?? "unknown"}</code>
</td>
<td>{prop.required ? <strong>Yes</strong> : "No"}</td>
<td>
{prop.literalValues?.length ? (
<code>{prop.literalValues.map(formatLiteral).join(" | ")}</code>
) : (
<span className={styles.muted}>—</span>
)}
</td>
<td>{prop.description ?? <span className={styles.muted}>—</span>}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function componentAnchor(name: string) {
return `custom-jsx-component-${name
.replaceAll(".", "-")
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
.toLowerCase()}`;
}
function humanize(value: string) {
return value.replaceAll("-", " ").replace(/\b\w/g, (character) => character.toUpperCase());
}
function formatLiteral(value: string | number | boolean | null) {
return typeof value === "string" ? JSON.stringify(value) : String(value);
}
@@ -0,0 +1,116 @@
"use client";
import { useState } from "react";
import { MantineProvider, Select, Stack, Text } from "@mantine/core";
/* oxlint-disable import/no-unassigned-import -- Mantine component styles intentionally load for side effects. */
import "@mantine/core/styles.layer.css";
/* oxlint-enable import/no-unassigned-import */
import {
BUNDLED_CUSTOM_WIDGETS,
CUSTOM_WIDGET_OPTIONS_EXAMPLES,
CUSTOM_WIDGET_REQUEST_EXAMPLES,
} from "@homarr/custom-widgets/core";
import type { CustomWidgetEditorMessages } from "@homarr/custom-widgets/workbench";
import { CustomWidgetCodeEditor, ReadOnlyCustomWidgetCode } from "@homarr/custom-widgets/workbench";
import { useColorMode } from "@/hooks/use-color-mode";
import type {
CanonicalCustomWidgetExampleProps,
CustomWidgetCodeExampleProps,
CustomWidgetCodeInputProps,
} from "./custom-widget-code";
const messages: CustomWidgetEditorMessages = {
languageJsx: "JSX",
languageJson: "JSON",
undo: "Undo",
redo: "Redo",
components: "Components",
componentSearch: "Search components",
componentEmpty: "No matching components",
componentCount: (count) => `${count} components`,
insertStarter: "Insert starter",
format: "Format",
copy: "Copy",
copied: "Copied",
schema: "Schema",
schemaTab: "JSON Schema",
minimalTab: "Minimal example",
fullTab: "Full example",
errors: (count) => `${count} errors`,
warnings: (count) => `${count} warnings`,
ready: "Read-only example",
position: ({ line, column }) => `Ln ${line}, Col ${column}`,
characters: (count, limit) => (limit ? `${count} / ${limit}` : `${count} characters`),
diagnosticsTitle: "Diagnostics",
diagnostic: (diagnostic) => diagnostic.value ?? diagnostic.code,
};
const editableMessages: CustomWidgetEditorMessages = { ...messages, ready: "Ready" };
export function CanonicalCustomWidgetExampleClient({ id, label, example, height }: CanonicalCustomWidgetExampleProps) {
const value = example === "requests" ? CUSTOM_WIDGET_REQUEST_EXAMPLES.full : CUSTOM_WIDGET_OPTIONS_EXAMPLES.full;
return <CustomWidgetCodeExampleClient id={id} label={label} code={JSON.stringify(value, null, 2)} height={height} />;
}
export function CustomWidgetCodeExampleClient(props: CustomWidgetCodeExampleProps) {
const { colorMode } = useColorMode();
return (
<MantineProvider forceColorScheme={colorMode}>
<ReadOnlyCustomWidgetCode
id={props.id}
label={props.label}
language={props.language ?? "json"}
value={props.code}
messages={messages}
height={props.height}
/>
</MantineProvider>
);
}
export function CustomWidgetCodeInputClient(props: CustomWidgetCodeInputProps) {
const { colorMode } = useColorMode();
return (
<MantineProvider forceColorScheme={colorMode}>
<CustomWidgetCodeEditor {...props} messages={editableMessages} />
</MantineProvider>
);
}
export function BundledCustomWidgetGalleryClient() {
const { colorMode } = useColorMode();
const [selectedId, setSelectedId] = useState(BUNDLED_CUSTOM_WIDGETS[0]?.id ?? null);
const selected = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === selectedId) ?? BUNDLED_CUSTOM_WIDGETS[0];
if (!selected) return null;
return (
<MantineProvider forceColorScheme={colorMode}>
<Stack gap="md">
<Select
label="Bundled widget"
data={BUNDLED_CUSTOM_WIDGETS.map(({ id, widget }) => ({ value: id, label: widget.name }))}
value={selected.id}
onChange={(value) => {
if (value) setSelectedId(value);
}}
allowDeselect={false}
/>
<Text size="sm" c="dimmed">
{selected.widget.description}
</Text>
<ReadOnlyCustomWidgetCode
id={`${selected.id}-manifest`}
label="Complete widget.json"
language="json"
value={JSON.stringify(selected.widget, null, 2)}
messages={messages}
height="680px"
/>
</Stack>
</MantineProvider>
);
}
@@ -0,0 +1,50 @@
import {
BundledCustomWidgetGalleryClient,
CanonicalCustomWidgetExampleClient,
CustomWidgetCodeInputClient,
CustomWidgetCodeExampleClient,
} from "./custom-widget-code-client";
export interface CustomWidgetCodeExampleProps {
id: string;
label: string;
code: string;
language?: "json" | "jsx";
height?: string;
}
export interface CustomWidgetCodeInputProps {
id: string;
label: string;
value: string;
onChange(value: string): void;
language: "json" | "css";
description?: string;
placeholder?: string;
height?: string;
required?: boolean;
maxLength?: number;
}
export interface CanonicalCustomWidgetExampleProps {
id: string;
label: string;
example: "requests" | "options";
height?: string;
}
export function CanonicalCustomWidgetExample(props: CanonicalCustomWidgetExampleProps) {
return <CanonicalCustomWidgetExampleClient {...props} />;
}
export function CustomWidgetCodeExample(props: CustomWidgetCodeExampleProps) {
return <CustomWidgetCodeExampleClient {...props} />;
}
export function CustomWidgetCodeInput(props: CustomWidgetCodeInputProps) {
return <CustomWidgetCodeInputClient {...props} />;
}
export function BundledCustomWidgetGallery() {
return <BundledCustomWidgetGalleryClient />;
}
@@ -0,0 +1,141 @@
"use client";
import { useEffect, useRef, useState } from "react";
import type { KeyboardEvent, PointerEvent } from "react";
import type { MascotScene } from "./scene";
import styles from "./styles.module.css";
declare global {
interface Window {
Kapa?: ((method: "open", options: { mode: "ai" }) => void) & {
open?: (options: { mode: "ai" }) => void;
};
}
}
export default function DocsMascot() {
const host = useRef<HTMLSpanElement>(null);
const scene = useRef<MascotScene | null>(null);
const drag = useRef({ x: 0, y: 0, active: false, moved: false });
const [ready, setReady] = useState(false);
const [message, setMessage] = useState("");
const logoUrl = "/img/mascot/homarr.svg";
const fallbackUrl = "/img/logo.png";
useEffect(() => {
const controller = new AbortController();
let dispose: (() => void) | undefined;
async function load() {
try {
const { createMascotScene } = await import("./scene");
const response = await fetch(logoUrl, { signal: controller.signal });
if (!response.ok) throw new Error("Logo unavailable");
const svg = await response.text();
if (controller.signal.aborted || !host.current) return;
const model = createMascotScene(host.current, svg);
scene.current = model;
dispose = model.dispose;
setReady(true);
} catch {
// The static logo remains a working chat launcher without WebGL.
}
}
void load();
return () => {
controller.abort();
dispose?.();
scene.current = null;
};
}, [logoUrl]);
function openChat() {
if (drag.current.moved) {
drag.current.moved = false;
return;
}
scene.current?.wink();
if (!window.Kapa || !window.Kapa.open) {
setMessage("Chat is still loading. Try again in a moment.");
return;
}
setMessage("");
window.Kapa("open", { mode: "ai" });
}
function startDrag(event: PointerEvent<HTMLButtonElement>) {
if (event.button !== 0) return;
drag.current = { x: event.clientX, y: event.clientY, active: true, moved: false };
event.currentTarget.setPointerCapture(event.pointerId);
}
function moveDrag(event: PointerEvent<HTMLButtonElement>) {
if (!drag.current.active) return;
const dx = event.clientX - drag.current.x;
const dy = event.clientY - drag.current.y;
if (!drag.current.moved && Math.hypot(dx, dy) < 5) return;
drag.current.moved = true;
scene.current?.rotate(dx * 0.018, dy * 0.012);
drag.current.x = event.clientX;
drag.current.y = event.clientY;
}
function keyDown(event: KeyboardEvent<HTMLButtonElement>) {
if (event.key === "Enter" || event.key === " ") drag.current.moved = false;
const model = scene.current;
if (!model) return;
switch (event.key) {
case "ArrowLeft":
model.rotate(-0.25, 0);
break;
case "ArrowRight":
model.rotate(0.25, 0);
break;
case "ArrowUp":
model.rotate(0, -0.15);
break;
case "ArrowDown":
model.rotate(0, 0.15);
break;
case "w":
model.wink();
break;
case "r":
model.reset();
break;
default:
return;
}
event.preventDefault();
}
return (
<div className={styles.mascot}>
<button
type="button"
className={styles.launcher}
aria-label="Ask AI"
onClick={openChat}
onPointerDown={startDrag}
onPointerMove={moveDrag}
onPointerEnter={() => scene.current?.setHovered(true)}
onPointerLeave={() => scene.current?.setHovered(false)}
onPointerUp={() => {
drag.current.active = false;
}}
onPointerCancel={() => {
drag.current.active = false;
}}
onLostPointerCapture={() => {
drag.current.active = false;
}}
onKeyDown={keyDown}
>
<span className={styles.model} ref={host} aria-hidden="true">
{!ready && <img src={fallbackUrl} alt="" draggable={false} />}
</span>
<span className={styles.label}>Ask AI</span>
</button>
{message && <output className={styles.status}>{message}</output>}
</div>
);
}
@@ -0,0 +1,177 @@
import * as THREE from "three";
import { initialConfig } from "../../../../../tools/motion-reel/three/lab-types";
import { extrudeSvg } from "../../../../../tools/motion-reel/three/logo-rig";
export interface MascotScene {
rotate: (x: number, y: number) => void;
wink: () => void;
setHovered: (hovered: boolean) => void;
reset: () => void;
dispose: () => void;
}
export function createMascotScene(host: HTMLElement, svg: string): MascotScene {
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setClearColor(0x000000, 0);
renderer.outputColorSpace = THREE.SRGBColorSpace;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(34, 1, 0.1, 100);
camera.position.set(0, 0, 12);
scene.add(new THREE.HemisphereLight(0xffffff, 0x57343e, 2.4));
const key = new THREE.DirectionalLight(0xfff1e7, 4);
key.position.set(-3, 5, 7);
scene.add(key);
const rim = new THREE.DirectionalLight(0xb3caff, 3);
rim.position.set(4, 1, -3);
scene.add(rim);
const { model, rig } = extrudeSvg(svg, initialConfig);
const pivot = new THREE.Group();
pivot.add(model);
scene.add(pivot);
host.appendChild(renderer.domElement);
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
let visible = true;
let disposed = false;
let frame = 0;
let lastRender = 0;
let yaw = -0.28;
let pitch = -0.1;
let hovered = false;
let greetingStarted = -1000;
let winkStarted = -1000;
let nextWink = performance.now() + 6500;
function draw(now: number) {
frame = 0;
if (disposed || document.hidden || !visible) return;
if (now - lastRender >= 32 || reducedMotion.matches) {
const delta = Math.min((now - lastRender) / 1000, 0.1);
lastRender = now;
let wink = 1;
if (!reducedMotion.matches) {
if (!hovered) yaw += delta * 0.28;
if (now > nextWink) {
winkStarted = now;
nextWink = now + 8000 + Math.random() * 6000;
}
const progress = (now - winkStarted) / 420;
if (progress >= 0 && progress <= 1) wink = 1 - Math.sin(progress * Math.PI) * 0.94;
}
pivot.rotation.set(pitch, yaw, 0);
const eye = rig.eyes[1];
if (eye) eye.scale.y = wink;
// Reuse the closing-claw and 3D antenna motion from the reel's poseLobster loop.
let motionStrength = 0.15;
const greetingProgress = (now - greetingStarted) / 700;
let greeting = 0;
if (greetingProgress >= 0 && greetingProgress <= 1) greeting = Math.sin(greetingProgress * Math.PI);
if (reducedMotion.matches) {
motionStrength = 0;
greeting = 0;
}
const t = now / 1000;
for (const claw of rig.claws) {
let offset = 0;
if (claw.side === 1) offset = 0.55;
const cycle = (1 - Math.cos(t * initialConfig.clawSpeed * initialConfig.clawFrequency * 2.2 + offset)) / 2;
const closure =
Math.pow(cycle, 1.8) * 0.6 * initialConfig.clawStrength * 1.25 * motionStrength + greeting * 0.025;
claw.middle.rotation.z = -claw.side * closure * 2.1;
claw.tip.rotation.z = -claw.side * closure;
claw.shoulder.rotation.z = claw.side * (Math.sin(t * 1.4 + offset) * 0.06 * motionStrength + greeting * 0.04);
}
for (const antenna of rig.antennae) {
let offset = 0;
if (antenna.side === 1) offset = 0.55;
const strength = initialConfig.antennaStrength * 1.4 * motionStrength;
const phase = t * initialConfig.antennaSpeed * 3.4 + offset;
antenna.middle.rotation.z = Math.sin(phase) * strength * 0.7;
antenna.tip.rotation.z = Math.sin(phase - 0.85) * strength * 1.25;
antenna.middle.rotation.x = Math.sin(phase * 0.72) * strength * 0.2;
antenna.tip.rotation.x = Math.sin(phase * 0.72 - 0.65) * strength * 0.45;
}
renderer.render(scene, camera);
}
if (!reducedMotion.matches) frame = requestAnimationFrame(draw);
}
function wake() {
if (disposed || frame || document.hidden || !visible) return;
lastRender = 0;
frame = requestAnimationFrame(draw);
}
function pause() {
cancelAnimationFrame(frame);
frame = 0;
}
function onVisibility() {
if (document.hidden) pause();
else wake();
}
const resize = new ResizeObserver(() => {
renderer.setSize(host.clientWidth, host.clientHeight, false);
camera.aspect = host.clientWidth / Math.max(host.clientHeight, 1);
camera.updateProjectionMatrix();
wake();
});
resize.observe(host);
const intersection = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
if (visible) wake();
else pause();
});
intersection.observe(host);
document.addEventListener("visibilitychange", onVisibility);
reducedMotion.addEventListener("change", wake);
wake();
return {
rotate(x, y) {
yaw += x;
pitch += y;
wake();
},
setHovered(value) {
if (value && !hovered && !reducedMotion.matches) {
greetingStarted = performance.now();
winkStarted = greetingStarted;
nextWink = greetingStarted + 10000;
}
hovered = value;
wake();
},
wink() {
if (reducedMotion.matches) return;
winkStarted = performance.now();
wake();
},
reset() {
yaw = -0.28;
pitch = -0.1;
wake();
},
dispose() {
disposed = true;
pause();
resize.disconnect();
intersection.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
reducedMotion.removeEventListener("change", wake);
model.traverse((object) => {
if (!(object instanceof THREE.Mesh)) return;
object.geometry.dispose();
const materials = [object.material].flat();
for (const material of materials) material.dispose();
if (object instanceof THREE.SkinnedMesh) object.skeleton.dispose();
});
renderer.dispose();
renderer.domElement.remove();
},
};
}
@@ -0,0 +1,81 @@
.mascot {
position: fixed;
bottom: max(6px, env(safe-area-inset-bottom));
right: max(6px, env(safe-area-inset-right));
z-index: 40;
}
.launcher {
display: flex;
flex-direction: column;
align-items: center;
padding: 0;
border: 0;
border-radius: 12px;
background: transparent;
color: var(--color-fd-foreground);
font: inherit;
cursor: pointer;
touch-action: none;
user-select: none;
}
.launcher:active {
cursor: grabbing;
}
.launcher:focus-visible {
outline: 2px solid var(--color-fd-primary);
outline-offset: 2px;
}
.model {
display: block;
width: 68px;
height: 68px;
}
.model canvas {
display: block;
width: 100%;
height: 100%;
}
.model img {
width: 100%;
height: 100%;
padding: 8px;
object-fit: contain;
}
.label {
margin-top: -5px;
font-size: 10px;
font-weight: 600;
line-height: 16px;
padding: 2px 9px;
border: 1px solid var(--color-fd-border);
border-radius: 999px;
background: var(--color-fd-background);
}
.status {
position: absolute;
bottom: 4px;
right: calc(100% + 6px);
width: max-content;
max-width: min(240px, calc(100vw - 90px));
padding: 6px 8px;
border: 1px solid var(--color-fd-border);
border-radius: 8px;
background: var(--color-fd-popover);
color: var(--color-fd-popover-foreground);
font-size: 11px;
}
.status {
bottom: calc(100% + 4px);
right: 0;
}
@media (max-width: 600px) {
.model {
width: 56px;
height: 56px;
}
}
@@ -0,0 +1,171 @@
"use client";
import type { WidgetKind } from "@homarr/definitions";
import { widgetCatalogIcons } from "@homarr/ui/widget-icons";
import { IconSearch, IconX } from "@tabler/icons-react";
import Link from "next/link";
import { useMemo, useRef, useState } from "react";
export interface DirectoryIntegration {
iconUrl: string;
name: string;
}
export interface DirectoryItem {
description?: string;
iconUrl?: string;
supportedIntegrations?: readonly DirectoryIntegration[];
title: string;
url: string;
widgetKind?: WidgetKind;
}
interface DirectoryGridProps {
items: DirectoryItem[];
label: string;
}
export function DirectoryGrid({ items, label }: DirectoryGridProps) {
const [query, setQuery] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const clearFilter = () => {
setQuery("");
inputRef.current?.focus();
};
const visibleItems = useMemo(() => {
const normalizedQuery = query.trim().toLocaleLowerCase();
if (!normalizedQuery) return items;
return items.filter(({ description, title }) =>
`${title} ${description ?? ""}`.toLocaleLowerCase().includes(normalizedQuery),
);
}, [items, query]);
return (
<nav className="not-prose my-8" aria-label={label}>
<div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<p className="m-0 text-sm text-fd-muted-foreground" aria-live="polite">
{visibleItems.length} of {items.length} {label}
</p>
<label className="relative block w-full sm:w-72">
<span className="sr-only">Filter {label}</span>
<IconSearch
aria-hidden="true"
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-fd-muted-foreground"
size={17}
/>
<input
className="h-10 w-full rounded-md border bg-fd-background pl-9 pr-9 text-sm text-fd-foreground outline-none placeholder:text-fd-muted-foreground focus-visible:ring-2 focus-visible:ring-fd-ring"
ref={inputRef}
type="search"
inputMode="search"
aria-label={`Filter ${label}`}
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={`Filter ${label}`}
/>
{query && (
<button
type="button"
className="absolute right-1.5 top-1/2 flex size-7 -translate-y-1/2 items-center justify-center rounded text-fd-muted-foreground hover:bg-fd-muted hover:text-fd-foreground"
onClick={clearFilter}
aria-label={`Clear ${label} filter`}
>
<IconX aria-hidden="true" size={16} />
</button>
)}
</label>
</div>
{visibleItems.length > 0 ? (
<ul className="m-0 grid list-none gap-px border bg-fd-border p-0 sm:grid-cols-2 xl:grid-cols-3">
{visibleItems.map((item) => {
const WidgetIcon = item.widgetKind ? widgetCatalogIcons[item.widgetKind] : undefined;
const integrations = item.supportedIntegrations ?? [];
const visibleIntegrations = integrations.slice(0, 6);
const remainingIntegrations = integrations.length - visibleIntegrations.length;
const supportedIntegrationNames = integrations.map((integration) => integration.name).join(", ");
return (
<li key={item.url} className="m-0 bg-fd-background p-0">
<Link
href={item.url}
className="group flex h-full min-h-24 items-start gap-3 p-4 text-fd-foreground hover:bg-fd-muted/60 hover:no-underline"
>
{(WidgetIcon || item.iconUrl) && (
<span
className="mt-0.5 flex size-10 shrink-0 items-center justify-center rounded-md border bg-fd-card p-1.5"
aria-hidden="true"
>
{WidgetIcon ? (
<WidgetIcon aria-hidden="true" className="size-6 text-fd-primary" stroke={1.8} />
) : (
<img
src={item.iconUrl}
alt=""
width={28}
height={28}
loading="lazy"
decoding="async"
className="size-full object-contain"
/>
)}
</span>
)}
<span className="min-w-0">
<strong className="block text-sm font-semibold">{item.title}</strong>
{item.description && (
<span className="mt-1.5 line-clamp-3 text-sm leading-5 text-fd-muted-foreground">
{item.description}
</span>
)}
{visibleIntegrations.length > 0 && (
<span className="mt-3 flex items-center gap-2 text-xs text-fd-muted-foreground">
<span className="shrink-0" aria-hidden="true">
Supports
</span>
<span
className="flex min-w-0 flex-wrap items-center gap-1"
role="img"
aria-label={supportedIntegrationNames}
>
{visibleIntegrations.map((integration) => (
<span
key={integration.name}
className="flex size-6 items-center justify-center rounded border bg-fd-card p-1"
title={integration.name}
>
<img
src={integration.iconUrl}
alt=""
width={16}
height={16}
loading="lazy"
decoding="async"
className="size-full object-contain"
/>
</span>
))}
{remainingIntegrations > 0 && (
<span className="px-1 font-medium text-fd-muted-foreground">+{remainingIntegrations}</span>
)}
</span>
</span>
)}
</span>
</Link>
</li>
);
})}
</ul>
) : (
<div className="rounded-lg border border-dashed p-6 text-center text-sm text-fd-muted-foreground">
<p>No guides match “{query}”. Try a service name or a feature, such as calendar or storage.</p>
<button type="button" className="mt-3 text-fd-primary underline underline-offset-4" onClick={clearFilter}>
Clear filter
</button>
</div>
)}
</nav>
);
}
@@ -0,0 +1,70 @@
import { integrationDefs, type IntegrationKind, type WidgetKind, widgetIntegrationSupport } from "@homarr/definitions";
import { widgetDocSlugs } from "@homarr/definitions/docs/widget-doc-slugs";
import { dockerIntegration } from "@site/docs/integrations/docker";
import { kubernetesIntegration } from "@site/docs/integrations/kubernetes";
import { getPageDescription } from "@/lib/page-description";
import { source } from "@/lib/source";
import { DirectoryGrid, type DirectoryIntegration } from "./directory-grid";
const sectionLabels = {
integrations: "integration guides",
widgets: "widget guides",
} as const;
const integrationIconsBySlug = Object.values(integrationDefs).reduce<Record<string, string>>((icons, definition) => {
if (definition.documentationSlug) icons[definition.documentationSlug] = definition.iconUrl;
return icons;
}, {});
const widgetKindsBySlug = Object.entries(widgetDocSlugs).reduce<Record<string, WidgetKind>>((kinds, [kind, slug]) => {
if (slug) kinds[slug] = kind as WidgetKind;
return kinds;
}, {});
Object.assign(widgetKindsBySlug, { "archive-team-warrior": "archiveTeamWarrior" as WidgetKind });
const getSupportedIntegrations = (widgetKind: WidgetKind): DirectoryIntegration[] => {
const supportedIntegrations = widgetIntegrationSupport[widgetKind] ?? [];
const seen = new Set<IntegrationKind>();
return supportedIntegrations.flatMap((integrationKind) => {
if (integrationKind === "mock" || seen.has(integrationKind)) return [];
seen.add(integrationKind);
const integration = integrationDefs[integrationKind];
return [{ iconUrl: integration.iconUrl, name: integration.name }];
});
};
Object.assign(integrationIconsBySlug, {
docker: dockerIntegration.iconUrl,
"docker-labels": dockerIntegration.iconUrl,
kubernetes: kubernetesIntegration.iconUrl,
});
interface DocsDirectoryProps {
section: keyof typeof sectionLabels;
}
export async function DocsDirectory({ section }: DocsDirectoryProps) {
const pages = source.getPages().filter((page) => page.slugs[0] === section && page.slugs.length === 2);
const items = (
await Promise.all(
pages.map(async (page) => ({
description: await getPageDescription(page),
iconUrl: section === "integrations" ? integrationIconsBySlug[page.slugs[1]] : undefined,
supportedIntegrations:
section === "widgets" && widgetKindsBySlug[page.slugs[1]]
? getSupportedIntegrations(widgetKindsBySlug[page.slugs[1]])
: undefined,
title: page.data.title,
url: page.url,
widgetKind: section === "widgets" ? widgetKindsBySlug[page.slugs[1]] : undefined,
})),
)
).toSorted((a, b) => a.title.localeCompare(b.title));
return <DirectoryGrid items={items} label={sectionLabels[section]} />;
}
@@ -0,0 +1,144 @@
"use client";
import { usePathname } from "fumadocs-core/framework";
import Link from "fumadocs-core/link";
import type { Folder, Node } from "fumadocs-core/page-tree";
import { IconChevronDown } from "@tabler/icons-react";
import {
SidebarFolder,
SidebarFolderContent,
SidebarFolderTrigger,
useFolder,
useFolderDepth,
} from "fumadocs-ui/components/sidebar/base";
import type { MouseEvent, ReactNode } from "react";
const normalizePath = (path: string) => (path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path);
const isActivePath = (url: string, pathname: string, nested = false) => {
const normalizedUrl = normalizePath(url);
const normalizedPathname = normalizePath(pathname);
return normalizedUrl === normalizedPathname || (nested && normalizedPathname.startsWith(`${normalizedUrl}/`));
};
const containsPath = (node: Node, pathname: string): boolean => {
if (node.type === "page") return isActivePath(node.url, pathname);
if (node.type === "folder") {
return Boolean(
(node.index && isActivePath(node.index.url, pathname, true)) ||
node.children.some((child) => containsPath(child, pathname)),
);
}
return false;
};
const folderItemClass =
"relative flex flex-row items-center gap-2 rounded-lg p-2 text-start text-fd-muted-foreground wrap-anywhere [&_svg]:size-4 [&_svg]:shrink-0 transition-colors hover:bg-fd-accent/50 hover:text-fd-accent-foreground/80 hover:transition-none";
const getItemOffset = (depth: number) => `calc(${2 + 3 * depth} * var(--spacing))`;
function ToggleableFolderLink({
active,
children,
external,
href,
}: {
active: boolean;
children: ReactNode;
external?: boolean;
href: string;
}) {
const folder = useFolder();
const depth = useFolderDepth();
const onClick = (event: MouseEvent<HTMLAnchorElement>) => {
if (!folder?.collapsible || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const target = event.target;
if (target instanceof Element && target.closest("[data-icon]")) {
event.preventDefault();
folder.setOpen((open) => !open);
return;
}
if (active) {
event.preventDefault();
folder.setOpen((open) => !open);
return;
}
folder.setOpen(true);
};
return (
<Link
href={href}
external={external}
onClickCapture={onClick}
data-active={active}
className={`${folderItemClass} w-full data-[active=true]:bg-fd-primary/10 data-[active=true]:text-fd-primary data-[active=true]:hover:transition-colors`}
style={{ paddingInlineStart: getItemOffset(depth - 1) }}
>
{children}
{folder?.collapsible && (
<IconChevronDown
aria-hidden="true"
data-icon="true"
className={`ms-auto transition-transform ${folder.open ? "" : "-rotate-90 rtl:rotate-90"}`}
/>
)}
</Link>
);
}
function StyledFolderTrigger({ children }: { children: ReactNode }) {
const depth = useFolderDepth();
const folder = useFolder();
return (
<SidebarFolderTrigger
className={`${folderItemClass} ${folder?.collapsible ? "transition-colors hover:bg-fd-accent/50 hover:text-fd-accent-foreground/80" : ""} w-full`}
style={{ paddingInlineStart: getItemOffset(depth - 1) }}
>
{children}
</SidebarFolderTrigger>
);
}
function StyledFolderContent({ children }: { children: ReactNode }) {
const depth = useFolderDepth();
return (
<SidebarFolderContent
className={`relative ${depth === 1 ? "before:absolute before:inset-y-1 before:inset-s-2.5 before:w-px before:bg-fd-border before:content-['']" : ""}`}
>
<div className="flex flex-col gap-0.5 pt-0.5">{children}</div>
</SidebarFolderContent>
);
}
export function DocsSidebarFolder({ item, children }: { item: Folder; children: ReactNode }) {
const pathname = usePathname();
const active = containsPath(item, pathname);
return (
<SidebarFolder collapsible={item.collapsible} active={active} defaultOpen={item.defaultOpen}>
{item.index ? (
<ToggleableFolderLink
href={item.index.url}
active={isActivePath(item.index.url, pathname)}
external={item.index.external}
>
{item.icon}
{item.name}
</ToggleableFolderLink>
) : (
<StyledFolderTrigger>
{item.icon}
{item.name}
</StyledFolderTrigger>
)}
<StyledFolderContent>{children}</StyledFolderContent>
</SidebarFolder>
);
}
@@ -1,8 +1,8 @@
export const AddingIntegration = () => {
return (
<p>
You can find how to add the integration on the <a href="/docs/management/integrations">Integrations Management</a>{" "}
documentation page.
Create the connection under <strong>Management → Integrations</strong>. See{" "}
<a href="/docs/management/integrations">Managing integrations</a>.
</p>
);
};
@@ -1,6 +1,8 @@
"use client";
import { IntegrationDefinition } from "@site/src/types";
import { DocsHeader } from "../ui/header";
import { useColorMode } from "@docusaurus/theme-common";
import { useColorMode } from "@/hooks/use-color-mode";
interface IntegrationHeaderProps {
integration: IntegrationDefinition;
@@ -14,7 +16,14 @@ export const IntegrationHeader = (props: IntegrationHeaderProps) => {
<DocsHeader
title={props.integration.name}
description={props.integration.description}
icon={<img src={getIntegrationIconUrl(props.integration, isDarkTheme)} width={48} height={48} />}
icon={
<img
src={getIntegrationIconUrl(props.integration, isDarkTheme)}
width={48}
height={48}
alt={`${props.integration.name} logo`}
/>
}
categories={props.categories}
/>
);
@@ -1,10 +1,9 @@
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";
import Alert from "@/components/mdx/admonition";
import Tabs, { TabItem } from "@/components/mdx/legacy-tabs";
const secretKinds = {
export const secretKinds = {
apiKey: {
name: "API Key",
description: "API Key from the service for authentication.",
@@ -78,6 +77,26 @@ const secretKinds = {
description: "The slug or path identifier for the resource",
icon: IconLink,
},
wazuhIndexerUrl: {
name: "Indexer URL",
description: "URL of the Wazuh indexer (OpenSearch), usually https://wazuh:9200",
icon: IconLink,
},
wazuhIndexerUsername: {
name: "Indexer username",
description: "Wazuh indexer user with read access to wazuh-alerts-*, wazuh-monitoring-* and wazuh-states-*",
icon: IconUser,
},
wazuhIndexerPassword: {
name: "Indexer password",
description: "Password of the Wazuh indexer user",
icon: IconPassword,
},
wazuhDashboardUrl: {
name: "Dashboard URL",
description: "Optional. Public URL of the Wazuh dashboard, used for deep links from the widgets",
icon: IconLink,
},
};
type SecretKind = keyof typeof secretKinds;
@@ -98,8 +117,8 @@ export const IntegrationSecrets = ({ secrets }: IntegrationSecretsProps) => {
<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("-");
{secrets.map((secret, index) => {
const key = secret.credentials.join("-") || `no-authentication-${index}`;
const Icon =
secret.credentials.map((value) => secretKinds[value]).find((value) => "icon" in value)?.icon ||
IconKeyOff;
@@ -1,5 +1,7 @@
import { getDocsHref } from "@/lib/docs-path";
import { WidgetDefinition } from "@site/src/types";
import { IconExternalLink, TablerIcon } from "@tabler/icons-react";
import { IconArrowRight, type TablerIcon } from "@tabler/icons-react";
import Link from "next/link";
interface IntegrationCapabilitesProps {
items: (
@@ -21,35 +23,37 @@ interface IntegrationCapabilitesProps {
export const IntegrationCapabilites = ({ items }: IntegrationCapabilitesProps) => {
return (
<div className="flex flex-col gap-4 mt-4 w-full">
<div className="flex flex-col gap-2">
<div className="not-prose mt-4 grid w-full gap-2">
<div className="grid 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"
className="flex flex-col gap-4 rounded-xl border bg-fd-card p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex gap-6 items-center">
<div className="w-10 h-10 flex justify-center items-center bg-red-500/10 rounded-md">
<capability.icon size={24} stroke={1.5} className="stroke-red-500" />
<div className="flex min-w-0 items-start gap-4 sm:items-center">
<div className="flex size-10 shrink-0 items-center justify-center rounded-md bg-fd-primary/10 text-fd-primary">
<capability.icon aria-hidden="true" size={24} stroke={1.5} />
</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 className="min-w-0">
<span className="block text-base font-semibold text-fd-foreground">{capability.name}</span>
<span className="mt-1 block text-sm leading-5 text-fd-muted-foreground">
{capability.description}
</span>
{item.note && <span className="mt-1 block text-xs text-fd-muted-foreground">{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"
<Link
href={getDocsHref(capability.path)}
className="inline-flex shrink-0 items-center justify-center gap-2 rounded-md border px-3 py-2 text-sm font-medium text-fd-foreground hover:bg-fd-muted hover:no-underline"
>
<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>
View guide
<IconArrowRight aria-hidden="true" size={16} stroke={1.5} />
</Link>
</div>
);
})}
+24
View File
@@ -0,0 +1,24 @@
import type { MDXComponents } from "mdx/types";
import { ImageZoom } from "fumadocs-ui/components/image-zoom";
import defaultMdxComponents from "fumadocs-ui/mdx";
import type { ComponentProps } from "react";
import Admonition from "./mdx/admonition";
import { TabItem, Tabs } from "./mdx/legacy-tabs";
export function getMDXComponents(components?: MDXComponents) {
return {
...defaultMdxComponents,
img: (props) => <ImageZoom {...(props as ComponentProps<typeof ImageZoom>)} />,
Alert: Admonition,
TabItem,
Tabs,
...components,
} satisfies MDXComponents;
}
export const useMDXComponents = getMDXComponents;
declare global {
type MDXProvidedComponents = ReturnType<typeof getMDXComponents>;
}
@@ -0,0 +1,26 @@
import { Callout } from "fumadocs-ui/components/callout";
import type { ReactNode } from "react";
interface AdmonitionProps {
children: ReactNode;
title?: ReactNode;
type?: "caution" | "danger" | "info" | "note" | "success" | "tip" | "warning";
}
const typeMap = {
caution: "warn",
danger: "error",
info: "info",
note: "info",
success: "success",
tip: "idea",
warning: "warn",
} as const;
export default function Admonition({ children, title, type = "note" }: AdmonitionProps) {
return (
<Callout title={title} type={typeMap[type]}>
{children}
</Callout>
);
}
@@ -0,0 +1,76 @@
// oxlint-disable-next-line import/no-unassigned-import -- Prevent catalog data from entering a client bundle.
import "server-only";
import { asMarkdown } from "fumadocs-core/server";
import type { CustomJsxAuthoringCatalog, CustomJsxPropDescriptor } from "@homarr/custom-widgets/catalog";
import catalogData from "@site/public/custom-widgets/component-catalog-v1.json";
const catalog = catalogData as CustomJsxAuthoringCatalog;
function Props({ items }: { items: CustomJsxPropDescriptor[] }) {
asMarkdown();
return (
<ul>
{items.map((prop) => (
<li key={prop.name}>
<code>{prop.name}</code>: <code>{catalog.types[prop.typeRef] ?? "unknown"}</code>.
{prop.required && " Required."} {prop.description}
{prop.literalValues && (
<> Known values: {prop.literalValues.map((value) => JSON.stringify(value)).join(", ")}.</>
)}
</li>
))}
</ul>
);
}
export function ComponentCatalogMarkdown() {
return (
<>
<p>
Custom JSX {catalog.customWidgetVersion}, Mantine {catalog.mantineVersion}. Properties are optional unless
marked required. <a href="/custom-widgets/component-catalog-v1.json">Download the complete JSON catalog</a>.
</p>
<h2>Global properties</h2>
<Props items={catalog.globalProps} />
<h2>Blocked capabilities</h2>
<ul>
{catalog.blockedCapabilities.map((capability) => (
<li key={`${capability.kind}-${capability.name}`}>
<code>{capability.name}</code> ({capability.kind}): {capability.reason}
</li>
))}
</ul>
{catalog.components.map((component) => (
<section key={component.name}>
<h2>{component.name}</h2>
<p>
{component.package}; {component.category}; safety: {component.safety}. {component.description}
</p>
{component.deniedReason && <p>Unavailable: {component.deniedReason}</p>}
{component.bind && (
<p>
Bind a temporary {component.bind.type} input with <code>bind</code>. Initialize with{" "}
<code>{component.bind.initialProp}</code>; reset with <code>{component.bind.resetProp}</code>.
</p>
)}
{component.subcomponents.length > 0 && <p>Subcomponents: {component.subcomponents.join(", ")}.</p>}
<Props items={component.props} />
<ul>
{component.blockedProps.map((prop) => (
<li key={prop.name}>
<code>{prop.name}</code> is blocked: {prop.reason}
</li>
))}
{component.accessibilityRequirements.map((requirement) => (
<li key={requirement}>{requirement}</li>
))}
</ul>
<p>
<a href={component.documentationUrl}>Upstream documentation for {component.name}</a>
</p>
</section>
))}
</>
);
}
@@ -0,0 +1,40 @@
"use client";
import { Tab, Tabs as FumadocsTabs, TabsList, TabsTrigger } from "fumadocs-ui/components/tabs";
import { Children, isValidElement, type ReactElement, type ReactNode } from "react";
interface TabItemProps {
children: ReactNode;
className?: string;
default?: boolean;
label: ReactNode;
value: string;
}
export function TabItem({ children }: TabItemProps) {
return children;
}
export function Tabs({ children, className }: { children: ReactNode; className?: string }) {
const tabs = Children.toArray(children).filter(isValidElement) as ReactElement<TabItemProps>[];
const defaultValue = tabs.find((tab) => tab.props.default)?.props.value ?? tabs[0]?.props.value;
return (
<FumadocsTabs className={className} defaultValue={defaultValue}>
<TabsList>
{tabs.map((tab) => (
<TabsTrigger key={tab.props.value} value={tab.props.value}>
{tab.props.label}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
<Tab key={tab.props.value} value={tab.props.value} className={tab.props.className}>
{tab.props.children}
</Tab>
))}
</FumadocsTabs>
);
}
export default Tabs;
+12
View File
@@ -0,0 +1,12 @@
import NextLink, { type LinkProps as NextLinkProps } from "next/link";
import type { AnchorHTMLAttributes } from "react";
type LinkProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> &
Omit<NextLinkProps, "href"> & {
href?: NextLinkProps["href"];
to?: NextLinkProps["href"];
};
export default function Link({ href, to, ...props }: LinkProps) {
return <NextLink href={href ?? to ?? "#"} {...props} />;
}
@@ -0,0 +1,121 @@
import { asMarkdown } from "fumadocs-core/server";
import type { ComponentProps, ReactNode } from "react";
import { AddingIntegration } from "@/components/integrations/adding";
import type { IntegrationHeader } from "@/components/integrations/header";
import { secretKinds, type IntegrationSecrets } from "@/components/integrations/secrets";
import type { IntegrationCapabilites } from "@/components/integrations/widgets";
import { AddingWidget } from "@/components/widgets/adding";
import type { WidgetHeader } from "@/components/widgets/header";
import type { WidgetIntegrations } from "@/components/widgets/integrations";
import { WidgetInteractionGuide } from "@/components/widgets/interactions";
import { GettingStartedOverview, InstallationPaths } from "@/components/pages/getting-started/getting-started-overview";
import { getDocsHref } from "@/lib/docs-path";
import type { WidgetConfiguration } from "@/types";
import { ComponentCatalogMarkdown } from "./component-catalog-markdown";
// Fumadocs supplies the original MDX props, including typed metadata and JSX steps.
function markdown<Props>(component: (props: Props) => ReactNode) {
return (props: Props) => {
asMarkdown();
return component(props);
};
}
function configuration({ items }: WidgetConfiguration): ReactNode {
return items.map((item, index) => (
<div key={index}>
<p>
<strong>{item.name}</strong>: {item.description}
</p>
<p>
Values:{" "}
{typeof item.values === "string"
? item.values
: item.values.type === "boolean"
? "yes / no"
: item.values.type === "select"
? item.values.options.join(", ")
: item.values.type === "string"
? "String"
: `${item.values.name}${item.values.type === "array" ? "[]" : ""}`}
. Default: {item.defaultValue}.
</p>
{typeof item.values !== "string" && "configuration" in item.values
? configuration(item.values.configuration)
: null}
</div>
));
}
export const metadataMarkdownComponents = {
CustomJsxComponentReference: markdown(ComponentCatalogMarkdown),
GettingStartedOverview: markdown(GettingStartedOverview),
InstallationPaths: markdown(InstallationPaths),
AddingIntegration: markdown(AddingIntegration),
AddingWidget: markdown(AddingWidget),
WidgetInteractionGuide: markdown(WidgetInteractionGuide),
IntegrationHeader: markdown(({ integration, categories }: ComponentProps<typeof IntegrationHeader>) => (
<>
<p>{integration.description}</p>
<p>Categories: {categories.join(", ")}</p>
</>
)),
WidgetHeader: markdown(({ widget, categories }: ComponentProps<typeof WidgetHeader>) => (
<>
<p>{widget.description}</p>
<p>Categories: {categories.join(", ")}</p>
</>
)),
IntegrationSecrets: markdown(({ secrets }: ComponentProps<typeof IntegrationSecrets>) =>
secrets.map((secret, index) => (
<div key={index}>
{secret.tabLabel ? (
<p>
<strong>{secret.tabLabel}</strong>
</p>
) : null}
{secret.header}
{secret.credentials.length === 0 ? (
<p>No credentials required.</p>
) : (
secret.credentials.map((credential) => (
<p key={credential}>
<strong>{secretKinds[credential].name}</strong>: {secretKinds[credential].description}
</p>
))
)}
{secret.body}
{secret.steps.length > 0 ? (
<>
<p>Steps to retrieve the credentials:</p>
<ol>
{secret.steps.map((step, stepIndex) => (
<li key={stepIndex}>{step}</li>
))}
</ol>
</>
) : null}
{secret.footer}
</div>
)),
),
IntegrationCapabilites: markdown(({ items }: ComponentProps<typeof IntegrationCapabilites>) =>
items.map((item, index) => {
const capability = "widget" in item ? item.widget : item.capability;
return (
<p key={index}>
<a href={getDocsHref(capability.path)}>{capability.name}</a>: {capability.description} {item.note}
</p>
);
}),
),
WidgetIntegrations: markdown(({ items }: ComponentProps<typeof WidgetIntegrations>) =>
items.map(({ integration, note }) => (
<p key={integration.name}>
<a href={getDocsHref(integration.path)}>{integration.name}</a>: {integration.description} {note}
</p>
)),
),
WidgetConfig: markdown(({ configuration: value }: { configuration: WidgetConfiguration }) => configuration(value)),
};
+22
View File
@@ -0,0 +1,22 @@
"use client";
import { createOpenAPIPage } from "fumadocs-openapi/ui";
import { withScalar } from "fumadocs-openapi/ui/scalar";
import { Carbon } from "@/components/carbon";
export const OpenAPIPage = createOpenAPIPage(
withScalar({
showResponseSchema: true,
content: {
renderAPIExampleLayout: ({ selector, usageTabs, responseTabs }) => (
<div className="prose-no-margin">
{selector}
{usageTabs}
{responseTabs}
<Carbon />
</div>
),
},
}),
);
@@ -1,3 +1,5 @@
"use client";
import { useEffect, useState } from "react";
type Contributor = {
@@ -21,8 +23,14 @@ export const CodeContributorList = () => {
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={""} />
<div key={contributor.login} className={"flex flex-col items-center w-24"}>
<img
className={"mb-2 aspect-square h-24 w-24 rounded"}
src={contributor.avatar_url}
alt={""}
loading="lazy"
decoding="async"
/>
<h6 className={"truncate text-nowrap max-w-full"}>{contributor.login}</h6>
</div>
))}
@@ -1,3 +1,5 @@
"use client";
import { useEffect, useState } from "react";
type Contributor = {
@@ -18,8 +20,14 @@ export const TranslationContributorList = () => {
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={""} />
<div key={contributor.username} className={"flex flex-col items-center w-24"}>
<img
className={"mb-2 aspect-square h-24 w-24 rounded"}
src={contributor.avatarUrl}
alt={""}
loading="lazy"
decoding="async"
/>
<h6 className={"truncate text-nowrap max-w-full"}>{contributor.username}</h6>
</div>
))}
@@ -0,0 +1,401 @@
.diagram {
--diagram-ink: var(--color-fd-foreground);
--diagram-muted: var(--color-fd-muted-foreground);
--diagram-panel: var(--color-fd-card);
--diagram-surface: var(--color-fd-background);
--diagram-line: var(--color-fd-border);
display: grid;
gap: 0.9rem;
margin: 1.25rem 0 1.75rem;
padding: 1rem;
border: 1px solid var(--diagram-line);
border-radius: 0.75rem;
color: var(--diagram-ink);
background: var(--diagram-panel);
}
.routeBlock {
display: grid;
gap: 0.55rem;
}
.routeBlock + .routeBlock {
padding-top: 0.85rem;
border-top: 1px solid var(--diagram-line);
}
.routeHeading {
display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.routeHeading > span {
display: grid;
place-items: center;
color: var(--color-fd-primary);
}
.routeHeading strong {
font-size: 0.74rem;
}
.routeHeading small {
color: var(--diagram-muted);
font-family: var(--font-mono);
font-size: 0.58rem;
}
.threeStepLane,
.fourStepLane {
display: grid;
align-items: stretch;
gap: 0.35rem;
}
.threeStepLane {
grid-template-columns: minmax(0, 1fr) 3.25rem minmax(0, 1fr) 3.25rem minmax(0, 1fr);
}
.fourStepLane {
grid-template-columns: minmax(0, 1fr) 3.1rem minmax(0, 1fr) 3.1rem minmax(0, 1fr) 3.1rem minmax(0, 1fr);
}
.node {
display: grid;
align-content: start;
gap: 0.65rem;
min-width: 0;
min-height: 6.25rem;
padding: 0.7rem;
border: 1px solid var(--diagram-line);
border-radius: 0.55rem;
color: var(--diagram-ink);
background: var(--diagram-surface);
text-decoration: none;
}
.nodeLink:hover {
border-color: var(--color-fd-primary);
color: var(--diagram-ink);
text-decoration: none;
}
.nodeHeader {
display: flex;
align-items: flex-start;
gap: 0.5rem;
min-width: 0;
}
.nodeIcon,
.largeIcon {
display: grid;
flex: none;
place-items: center;
border: 1px solid var(--diagram-line);
color: var(--color-fd-primary);
background: var(--diagram-panel);
}
.nodeIcon {
width: 1.75rem;
height: 1.75rem;
border-radius: 0.45rem;
}
.nodeIcon img {
width: 1.15rem;
height: 1.15rem;
margin: 0;
object-fit: contain;
}
.nodeCopy {
display: grid;
min-width: 0;
}
.nodeCopy strong {
font-size: 0.72rem;
line-height: 1.25;
}
.nodeCopy small {
color: var(--diagram-muted);
font-size: 0.59rem;
line-height: 1.35;
}
.nodeBody {
display: grid;
align-content: start;
min-width: 0;
}
.nodeBody code {
overflow: hidden;
display: block;
padding: 0.25rem 0.35rem;
border: 1px solid var(--diagram-line);
border-radius: 0.3rem;
color: var(--diagram-muted);
background: var(--diagram-panel);
font-size: 0.53rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.pillList {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.pillList span,
.pillList a {
padding: 0.15rem 0.3rem;
border: 1px solid var(--diagram-line);
border-radius: 999px;
color: var(--diagram-muted);
background: var(--diagram-panel);
font-size: 0.5rem;
line-height: 1.25;
text-decoration: none;
}
.pillList a:hover {
border-color: var(--color-fd-primary);
color: var(--color-fd-primary);
text-decoration: none;
}
.localDetails,
.definitionParts {
display: grid;
gap: 0.25rem;
}
.localDetails span,
.definitionParts span {
display: flex;
align-items: center;
gap: 0.25rem;
color: var(--diagram-muted);
font-size: 0.53rem;
line-height: 1.3;
}
.definitionParts svg {
flex: none;
color: var(--color-fd-primary);
}
.diagramNote {
padding-top: 0.75rem;
border-top: 1px solid var(--diagram-line);
color: var(--diagram-muted);
font-size: 0.68rem;
line-height: 1.45;
}
.factList {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.4rem;
}
.factList span {
display: flex;
align-items: center;
gap: 0.35rem;
min-width: 0;
padding: 0.45rem;
border: 1px solid var(--diagram-line);
border-radius: 0.4rem;
color: var(--diagram-muted);
background: var(--diagram-surface);
font-size: 0.56rem;
line-height: 1.3;
}
.factList svg {
flex: none;
color: var(--color-fd-primary);
}
.boardSource {
display: flex;
align-items: center;
justify-self: center;
gap: 0.55rem;
min-width: 14rem;
padding: 0.65rem 0.8rem;
border: 1px solid var(--diagram-line);
border-radius: 0.55rem;
background: var(--diagram-surface);
}
.largeIcon {
width: 2.25rem;
height: 2.25rem;
border-radius: 0.5rem;
}
.boardSource > span:last-child {
display: grid;
}
.boardSource strong {
font-size: 0.78rem;
}
.boardSource small {
color: var(--diagram-muted);
font-size: 0.59rem;
}
.layoutGrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.55rem;
}
.layoutCard {
display: grid;
gap: 0.55rem;
min-width: 0;
padding: 0.65rem;
border: 1px solid var(--diagram-line);
border-radius: 0.55rem;
margin: 0;
background: var(--diagram-surface);
}
.layoutHeading {
display: flex;
align-items: center;
gap: 0.45rem;
}
.layoutHeading > span:first-child {
display: grid;
flex: none;
place-items: center;
width: 1.65rem;
height: 1.65rem;
border: 1px solid var(--diagram-line);
border-radius: 0.4rem;
color: var(--color-fd-primary);
background: var(--diagram-panel);
}
.layoutHeading > span:last-child {
display: grid;
min-width: 0;
}
.layoutHeading strong {
font-size: 0.7rem;
}
.layoutHeading code {
padding: 0;
border: 0;
color: var(--diagram-muted);
background: transparent;
font-size: 0.54rem;
white-space: normal;
}
.layoutCard > small {
color: var(--diagram-muted);
font-size: 0.55rem;
}
.miniBoard {
display: grid;
grid-auto-rows: 1.2rem;
gap: 0.22rem;
min-height: 4.25rem;
padding: 0.35rem;
border: 1px dashed var(--diagram-line);
border-radius: 0.35rem;
background: var(--diagram-panel);
}
.miniTile,
.miniRail {
display: block;
border: 1px solid var(--diagram-line);
border-radius: 0.22rem;
background: var(--diagram-surface);
}
.miniTile:nth-child(2) {
border-color: var(--color-fd-primary);
}
.mobileBoard {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.mobileBoard .miniRail {
display: none;
}
.mobileBoard .miniTile:nth-child(3),
.mobileBoard .miniTile:nth-child(5) {
grid-column: span 2;
}
.baseBoard {
grid-template-columns: 0.7fr repeat(4, minmax(0, 1fr));
}
.baseBoard .miniRail {
grid-row: 1 / span 3;
}
.baseBoard .miniTile:nth-child(3),
.baseBoard .miniTile:nth-child(4) {
grid-column: span 2;
}
.customBoard {
grid-template-columns: repeat(5, minmax(0, 1fr)) 0.7fr;
}
.customBoard .miniRail {
grid-column: 6;
grid-row: 1 / span 3;
}
.customBoard .miniTile:nth-child(3) {
grid-column: span 3;
}
.customBoard .miniTile:nth-child(5) {
grid-column: span 2;
}
@media (max-width: 900px) {
.threeStepLane,
.fourStepLane {
grid-template-columns: minmax(0, 1fr);
}
.node {
min-height: auto;
}
.factList,
.layoutGrid {
grid-template-columns: minmax(0, 1fr);
}
.miniBoard {
min-height: 5rem;
}
}
@@ -0,0 +1,423 @@
import type { ReactNode } from "react";
import Link from "next/link";
import {
IconApi,
IconBox,
IconBraces,
IconBrowser,
IconBuildingStore,
IconCode,
IconDeviceDesktop,
IconDeviceMobile,
IconKey,
IconLayoutBoard,
IconMessages,
IconPlugConnected,
IconRobot,
IconServer,
IconShieldCheck,
IconTool,
IconUser,
IconWorld,
} from "@tabler/icons-react";
import { sonarrIntegration } from "@site/docs/integrations/sonarr";
import { FlowConnector } from "@site/src/components/ui/flow-connector";
import styles from "./technical-diagrams.module.css";
const sonarrIconUrl = getIconUrl(sonarrIntegration.iconUrl);
export function AppFlowDiagram() {
return (
<figure className={styles.diagram} aria-label="How an app record is used on a board">
<div className={styles.threeStepLane}>
<DiagramNode icon={<IconBox size={19} />} title="App record" detail="Saved once in Homarr">
<div className={styles.pillList}>
<span>Name</span>
<span>Icon</span>
<span>URL</span>
<span>Ping URL · optional</span>
</div>
</DiagramNode>
<FlowConnector label="place" />
<DiagramNode icon={<IconLayoutBoard size={19} />} title="Board" detail="App tile or Bookmarks widget" />
<FlowConnector label="click" animationDelay={350} />
<DiagramNode icon={<IconBrowser size={19} />} title="Browser" detail="Opens the app URL directly" />
</div>
<DiagramNote>
If a Ping URL is set, the Homarr server checks that address for status. Clicking the tile still opens the app
URL.
</DiagramNote>
</figure>
);
}
export function IntegrationRoutesDiagram() {
return (
<figure className={styles.diagram} aria-label="Browser and server routes from Homarr to Sonarr">
<div className={styles.routeBlock}>
<RouteHeading icon={<IconBrowser size={16} />} title="Open the service" detail="Browser route" />
<div className={styles.threeStepLane}>
<DiagramNode icon={<IconLayoutBoard size={19} />} title="App tile" detail="Click from a board" />
<FlowConnector label="opens" />
<DiagramNode icon={<IconWorld size={19} />} title="App URL" detail="Browser-facing address">
<code>https://sonarr.example.com</code>
</DiagramNode>
<FlowConnector animationDelay={350} />
<DiagramNode
icon={<img src={sonarrIconUrl} alt="" />}
title="Sonarr UI"
detail="The browser connects directly"
href="/docs/integrations/sonarr"
/>
</div>
</div>
<div className={styles.routeBlock}>
<RouteHeading icon={<IconServer size={16} />} title="Use service data" detail="Server route" />
<div className={styles.threeStepLane}>
<DiagramNode icon={<IconApi size={19} />} title="Widget" detail="Requests data or an action" />
<FlowConnector label="Homarr" />
<DiagramNode
icon={<IconPlugConnected size={19} />}
title="Integration"
detail="URL and encrypted credentials"
href="/docs/management/integrations"
>
<code>http://sonarr:8989</code>
</DiagramNode>
<FlowConnector label="API" animationDelay={350} />
<DiagramNode
icon={<img src={sonarrIconUrl} alt="" />}
title="Sonarr API"
detail="The Homarr server connects"
href="/docs/integrations/sonarr"
/>
</div>
</div>
<DiagramNote>
Linking the app to the integration gives integration-backed widgets the browser-facing destination.
</DiagramNote>
</figure>
);
}
export function BoardLayoutsDiagram() {
return (
<figure className={styles.diagram} aria-label="How one board selects independent responsive layouts">
<div className={styles.boardSource}>
<span className={styles.largeIcon}>
<IconLayoutBoard size={23} aria-hidden="true" />
</span>
<span>
<strong>One board</strong>
<small>The same apps, widgets, and Containers</small>
</span>
</div>
<FlowConnector label="highest matching breakpoint" orientation="vertical" />
<div className={styles.layoutGrid}>
<LayoutCard
icon={<IconDeviceMobile size={18} />}
title="Mobile"
range="starts at 0px"
detail="Own positions · no rails"
variant="mobile"
/>
<LayoutCard
icon={<IconDeviceDesktop size={18} />}
title="Base"
range="starts at 768px"
detail="Own positions · rails available"
variant="base"
/>
<LayoutCard
icon={<IconLayoutBoard size={18} />}
title="Custom"
range="unique breakpoint"
detail="Own positions · rails available"
variant="custom"
/>
</div>
<DiagramNote>Moving or resizing an item in one layout does not change its coordinates in the others.</DiagramNote>
</figure>
);
}
export function AssistantFlowDiagram() {
return (
<figure className={styles.diagram} aria-label="How Homarr Assistant uses a model provider and Homarr tools">
<div className={styles.routeBlock}>
<RouteHeading icon={<IconMessages size={16} />} title="Conversation" detail="Every request" />
<div className={styles.threeStepLane}>
<DiagramNode icon={<IconUser size={19} />} title="You" detail="Prompt, attachment, or @ reference" />
<FlowConnector />
<DiagramNode
icon={<IconRobot size={19} />}
title="Assistant server"
detail="Loads live context and tool definitions"
/>
<FlowConnector animationDelay={350} />
<DiagramNode
icon={<IconWorld size={19} />}
title="Provider + model"
detail="Instance-wide provider; model may vary by conversation"
>
<div className={styles.pillList}>
<Link href="/docs/workshop/homarr-provider">Homarr</Link>
<span>Hosted</span>
<span>Local</span>
<span>Custom</span>
</div>
</DiagramNode>
</div>
</div>
<div className={styles.routeBlock}>
<RouteHeading icon={<IconTool size={16} />} title="When the model calls a tool" detail="Only when needed" />
<div className={styles.threeStepLane}>
<DiagramNode icon={<IconTool size={19} />} title="Tool request" detail="Read data or make a change" />
<FlowConnector />
<DiagramNode
icon={<IconShieldCheck size={19} />}
title="Permission boundary"
detail="Current user; approval for changes by default"
/>
<FlowConnector animationDelay={350} />
<DiagramNode
icon={<IconLayoutBoard size={19} />}
title="Homarr resources"
detail="Boards · apps · integrations · widgets · services"
href="/docs/management/mcp"
/>
</div>
</div>
<DiagramNote>
Tool results return to the model; the final answer streams back to the same conversation. The Homarr provider
routes model requests through Workshop. Other providers connect directly from the Homarr server.
</DiagramNote>
</figure>
);
}
export function HomarrProviderDiagram() {
return (
<figure className={styles.diagram} aria-label="How the Homarr Assistant provider routes model requests">
<div className={styles.fourStepLane}>
<DiagramNode icon={<IconMessages size={19} />} title="Assistant" detail="Per-user conversation" />
<FlowConnector label="request" />
<DiagramNode icon={<IconServer size={19} />} title="Homarr server" detail="Context, permissions, and tools" />
<FlowConnector label="token" animationDelay={350} />
<DiagramNode
icon={<IconBuildingStore size={19} />}
title="Workshop provider"
detail="Quota and homarr/model routing"
href="/docs/workshop"
/>
<FlowConnector label="model" animationDelay={700} />
<DiagramNode icon={<IconWorld size={19} />} title="Upstream model" detail="OpenRouter-compatible endpoint" />
</div>
<div className={styles.factList}>
<span>
<IconKey size={15} aria-hidden="true" /> No administrator API key
</span>
<span>
<IconApi size={15} aria-hidden="true" /> 50 request units per user and UTC day
</span>
<span>
<IconShieldCheck size={15} aria-hidden="true" /> Workshop does not store prompts or responses
</span>
</div>
<DiagramNote>
Tool calls still execute inside your Homarr instance with the signed-in user's permissions.
</DiagramNote>
</figure>
);
}
export function WorkshopFlowDiagram() {
return (
<figure className={styles.diagram} aria-label="How content moves through the Community Workshop">
<div className={styles.fourStepLane}>
<DiagramNode icon={<IconCode size={19} />} title="Author" detail="Build shareable content">
<div className={styles.pillList}>
<span>Custom Widget JSON</span>
<span>Custom CSS</span>
</div>
</DiagramNode>
<FlowConnector label="publish" />
<DiagramNode
icon={<IconBuildingStore size={19} />}
title="Workshop"
detail="Source, screenshots, and revision"
href="/workshop"
/>
<FlowConnector label="inspect" animationDelay={350} />
<DiagramNode
icon={<IconShieldCheck size={19} />}
title="Review + import"
detail="Validate source and requested access"
/>
<FlowConnector label="local" animationDelay={700} />
<DiagramNode icon={<IconLayoutBoard size={19} />} title="Your Homarr" detail="Configure and place on a board">
<div className={styles.localDetails}>
<span>Widget → local URLs + secrets</span>
<span>CSS → board custom CSS</span>
</div>
</DiagramNode>
</div>
<DiagramNote>
Workshop stores the shareable definition. Instance-specific URLs and credentials stay local.
</DiagramNote>
</figure>
);
}
export function CustomWidgetFlowDiagram() {
return (
<figure className={styles.diagram} aria-label="How a Custom Widget definition becomes a board tile">
<div className={styles.threeStepLane}>
<DiagramNode
icon={<IconBraces size={19} />}
title="Widget definition"
detail="homarr-custom-widget-v2"
href="/docs/management/custom-widgets/authoring"
>
<div className={styles.definitionParts}>
<span>
<IconApi size={14} /> API sources
</span>
<span>
<IconTool size={14} /> Queries + actions
</span>
<span>
<IconCode size={14} /> Safe JSX
</span>
</div>
</DiagramNode>
<FlowConnector label="validate" />
<DiagramNode
icon={<IconServer size={19} />}
title="Homarr runtime"
detail="Requests on the server; JSX in a restricted renderer"
href="/docs/management/custom-widgets/requests-and-security"
/>
<FlowConnector label="render" animationDelay={350} />
<DiagramNode icon={<IconLayoutBoard size={19} />} title="Board tile" detail="Live data and declared actions" />
</div>
<DiagramNote>
Source credentials are stored separately and excluded from exports, prompts, and Workshop submissions.
</DiagramNote>
</figure>
);
}
function RouteHeading({ icon, title, detail }: { icon: ReactNode; title: string; detail: string }) {
return (
<div className={styles.routeHeading}>
<span>{icon}</span>
<strong>{title}</strong>
<small>{detail}</small>
</div>
);
}
function DiagramNode({
icon,
title,
detail,
href,
children,
}: {
icon: ReactNode;
title: string;
detail: string;
href?: string;
children?: ReactNode;
}) {
const content = (
<>
<div className={styles.nodeHeader}>
<span className={styles.nodeIcon}>{icon}</span>
<span className={styles.nodeCopy}>
<strong>{title}</strong>
<small>{detail}</small>
</span>
</div>
{children && <div className={styles.nodeBody}>{children}</div>}
</>
);
if (href) {
return (
<Link className={`${styles.node} ${styles.nodeLink}`} href={href}>
{content}
</Link>
);
}
return <div className={styles.node}>{content}</div>;
}
function LayoutCard({
icon,
title,
range,
detail,
variant,
}: {
icon: ReactNode;
title: string;
range: string;
detail: string;
variant: "mobile" | "base" | "custom";
}) {
return (
<section className={styles.layoutCard}>
<div className={styles.layoutHeading}>
<span>{icon}</span>
<span>
<strong>{title}</strong>
<code>{range}</code>
</span>
</div>
<MiniBoard variant={variant} />
<small>{detail}</small>
</section>
);
}
function MiniBoard({ variant }: { variant: "mobile" | "base" | "custom" }) {
let className = styles.miniBoard;
className = `${className} ${styles[`${variant}Board`]}`;
return (
<div className={className} aria-hidden="true">
<span className={styles.miniRail} />
<span className={styles.miniTile} />
<span className={styles.miniTile} />
<span className={styles.miniTile} />
<span className={styles.miniTile} />
</div>
);
}
function DiagramNote({ children }: { children: ReactNode }) {
return <figcaption className={styles.diagramNote}>{children}</figcaption>;
}
function getIconUrl(iconUrl: typeof sonarrIntegration.iconUrl) {
if (typeof iconUrl === "string") return iconUrl;
return iconUrl.light;
}
@@ -0,0 +1,251 @@
.diagram {
--diagram-ink: var(--color-fd-foreground);
--diagram-muted: var(--color-fd-muted-foreground);
--diagram-panel: var(--color-fd-card);
--diagram-line: var(--color-fd-border);
--diagram-outline: color-mix(in oklch, var(--color-fd-foreground) 18%, var(--color-fd-border));
margin: 0;
padding: 0;
border: 0;
color: var(--diagram-ink);
background: transparent;
}
.lane {
display: grid;
grid-template-columns: minmax(8.5rem, 1.1fr) 2.25rem minmax(8.5rem, 1fr) 2.25rem minmax(8.5rem, 1fr) 2.25rem minmax(
8.5rem,
1fr
);
align-items: stretch;
gap: 0.25rem;
}
.stage {
display: grid;
grid-template-rows: auto 1fr;
gap: 0.55rem;
min-width: 0;
padding: 0.7rem;
border: 1px solid var(--diagram-outline);
border-radius: 0.65rem;
margin: 0;
background: color-mix(in oklch, var(--color-fd-primary) 2.5%, var(--diagram-panel));
}
.stageHeading {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--diagram-ink);
text-decoration: none;
}
a.stageHeading:hover {
color: var(--color-fd-primary);
text-decoration: none;
}
.stageHeading > span:last-child {
display: grid;
min-width: 0;
}
.stageHeading strong {
font-size: 0.78rem;
}
.stageHeading small {
color: var(--diagram-muted);
font-size: 0.62rem;
line-height: 1.3;
}
.stageNumber {
display: grid;
flex: none;
place-items: center;
width: 1.45rem;
height: 1.45rem;
border: 1px solid var(--diagram-line);
border-radius: 50%;
color: var(--diagram-muted);
font-family: var(--font-mono);
font-size: 0.64rem;
font-weight: 700;
}
.serviceList,
.widgetList {
display: grid;
align-content: center;
gap: 0.35rem;
}
.service,
.widget {
display: flex;
align-items: center;
gap: 0.45rem;
min-width: 0;
padding: 0.4rem;
border: 1px solid var(--diagram-line);
border-radius: 0.4rem;
color: var(--diagram-ink);
background: var(--diagram-panel);
text-decoration: none;
}
.service:hover,
.widget:hover {
border-color: var(--color-fd-primary);
color: var(--diagram-ink);
text-decoration: none;
}
.service img {
flex: none;
width: 1.25rem;
height: 1.25rem;
margin: 0;
}
.widget svg {
flex: none;
color: var(--diagram-muted);
}
.service > span {
display: grid;
min-width: 0;
}
.service strong,
.widget span {
overflow: hidden;
font-size: 0.67rem;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
}
.service code,
.integration code {
padding: 0;
border: 0;
color: var(--diagram-muted);
background: transparent;
font-size: 0.56rem;
line-height: 1.3;
white-space: nowrap;
}
.integration {
display: grid;
align-content: center;
justify-items: center;
gap: 0.35rem;
min-height: 4rem;
text-align: center;
}
.integration > svg {
color: var(--diagram-muted);
}
.integration > span {
color: var(--diagram-muted);
font-size: 0.61rem;
line-height: 1.35;
}
.boardCalendar {
display: grid;
align-content: center;
gap: 0.28rem;
min-height: 0;
padding: 0.38rem;
border: 1px solid var(--diagram-line);
border-radius: 0.4rem;
background: var(--diagram-panel);
}
.calendarHeader {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.3rem;
line-height: 1.2;
}
.calendarHeader strong {
font-size: 0.58rem;
}
.calendarHeader span {
color: var(--diagram-muted);
font-size: 0.44rem;
}
.calendarWeekdays,
.calendarDays {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 0.12rem;
}
.calendarWeekdays span {
color: var(--diagram-muted);
font-family: var(--font-mono);
font-size: 0.38rem;
line-height: 1.2;
text-align: center;
}
.calendarDays span {
position: relative;
display: grid;
place-items: center;
min-width: 0;
min-height: 1rem;
border-radius: 0.18rem;
color: var(--diagram-muted);
background: color-mix(in srgb, var(--diagram-line) 28%, transparent);
font-family: var(--font-mono);
font-size: 0.42rem;
line-height: 1;
}
.calendarDays span[data-event] {
color: var(--diagram-ink);
background: color-mix(in srgb, var(--diagram-line) 62%, transparent);
font-weight: 700;
}
.calendarDays span[data-event]::after {
position: absolute;
bottom: 0.08rem;
width: 0.12rem;
height: 0.12rem;
border-radius: 50%;
background: var(--diagram-muted);
content: "";
}
@media (max-width: 900px) {
.lane {
grid-template-columns: minmax(0, 1fr);
}
.serviceList,
.widgetList {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 430px) {
.serviceList,
.widgetList {
grid-template-columns: minmax(0, 1fr);
}
}
@@ -0,0 +1,154 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { IconCalendar, IconDownload, IconPlugConnected } from "@tabler/icons-react";
import { qBittorentIntegration } from "@site/docs/integrations/q-bittorent";
import { sonarrIntegration } from "@site/docs/integrations/sonarr";
import { FlowConnector } from "@site/src/components/ui/flow-connector";
import styles from "./concept-flow.module.css";
const services = [
{
name: sonarrIntegration.name,
iconUrl: getIconUrl(sonarrIntegration.iconUrl),
endpoint: "sonarr:8989",
href: "/docs/integrations/sonarr",
},
{
name: qBittorentIntegration.name,
iconUrl: getIconUrl(qBittorentIntegration.iconUrl),
endpoint: "qbittorrent:8080",
href: "/docs/integrations/q-bittorent",
},
];
const widgets = [
{
name: "Calendar",
icon: IconCalendar,
href: "/docs/widgets/calendar",
},
{
name: "Downloads",
icon: IconDownload,
href: "/docs/widgets/downloads",
},
];
const calendarDays = Array.from({ length: 14 }, (_, index) => index + 1);
const calendarEventDays = new Set([3, 8, 12]);
function getIconUrl(iconUrl: typeof sonarrIntegration.iconUrl) {
if (typeof iconUrl === "string") return iconUrl;
return iconUrl.light;
}
export function ConceptFlow() {
return (
<figure className={styles.diagram} aria-label="How Homarr connects services to a board">
<div className={styles.lane}>
<Stage number="1" title="Service" detail="API endpoint">
<div className={styles.serviceList}>
{services.map((service) => (
<Link className={styles.service} href={service.href} key={service.name}>
<img src={service.iconUrl} alt="" />
<span>
<strong>{service.name}</strong>
<code>{service.endpoint}</code>
</span>
</Link>
))}
</div>
</Stage>
<FlowConnector />
<Stage number="2" title="Integration" detail="Server-side connection" href="/docs/management/integrations">
<div className={styles.integration}>
<IconPlugConnected size={26} aria-hidden="true" />
<code>URL + credentials</code>
<span>Fetch data and run actions</span>
</div>
</Stage>
<FlowConnector animationDelay={350} />
<Stage number="3" title="Widget" detail="Data and actions" href="/docs/widgets">
<div className={styles.widgetList}>
{widgets.map((widget) => {
const Icon = widget.icon;
return (
<Link className={styles.widget} href={widget.href} key={widget.name}>
<Icon size={17} aria-hidden="true" />
<span>{widget.name}</span>
</Link>
);
})}
</div>
</Stage>
<FlowConnector animationDelay={700} />
<Stage number="4" title="Board" detail="Placed in the grid" href="/docs/management/boards">
<div className={styles.boardCalendar} aria-hidden="true">
<div className={styles.calendarHeader}>
<strong>September</strong>
<span>3 events</span>
</div>
<div className={styles.calendarWeekdays}>
{["M", "T", "W", "T", "F", "S", "S"].map((day, index) => (
<span key={`${day}-${index}`}>{day}</span>
))}
</div>
<div className={styles.calendarDays}>
{calendarDays.map((day) => (
<span data-event={calendarEventDays.has(day) || undefined} key={day}>
{day}
</span>
))}
</div>
</div>
</Stage>
</div>
</figure>
);
}
function Stage({
number,
title,
detail,
href,
children,
}: {
number: string;
title: string;
detail: string;
href?: string;
children?: ReactNode;
}) {
const heading = (
<>
<span className={styles.stageNumber}>{number}</span>
<span>
<strong>{title}</strong>
<small>{detail}</small>
</span>
</>
);
return (
<section className={styles.stage}>
{href ? (
<Link className={styles.stageHeading} href={href}>
{heading}
</Link>
) : (
<div className={styles.stageHeading}>{heading}</div>
)}
{children}
</section>
);
}
@@ -0,0 +1,243 @@
.overview,
.installPaths {
--start-ink: var(--color-fd-foreground);
--start-muted: var(--color-fd-muted-foreground);
--start-panel: var(--color-fd-card);
--start-line: var(--color-fd-border);
--start-line-strong: color-mix(in oklch, var(--color-fd-foreground) 18%, var(--color-fd-border));
--start-coral: var(--color-fd-primary);
}
.overview {
display: grid;
gap: 1rem;
margin: 1.25rem 0 2.5rem;
}
.conceptCard {
display: grid;
gap: 0.75rem;
padding: 0.9rem;
border: 1px solid color-mix(in oklch, var(--start-coral) 32%, var(--start-line));
border-radius: 0.75rem;
background: color-mix(in oklch, var(--start-coral) 4%, var(--start-panel));
}
.intro {
padding: 0;
}
.intro h2 {
margin: 0 0 0.25rem;
font-size: 1.35rem;
font-weight: 700;
line-height: 1.2;
}
.intro p {
max-width: 48rem;
margin: 0;
color: var(--start-muted);
line-height: 1.45;
}
.editor {
display: grid;
gap: 0.9rem;
padding-top: 0.25rem;
}
.editorHeading {
display: flex;
align-items: end;
justify-content: space-between;
gap: 1rem;
}
.editorHeading h3 {
margin: 0 0 0.25rem;
font-size: 1.1rem;
font-weight: 700;
}
.editorHeading p {
margin: 0;
color: var(--start-muted);
font-size: 0.84rem;
}
.editorHeading > a {
display: inline-flex;
flex: none;
align-items: center;
gap: 0.25rem;
font-size: 0.78rem;
font-weight: 700;
}
.editorSteps {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.65rem;
margin: 0;
padding: 0;
list-style: none;
}
.editorStep {
overflow: hidden;
display: grid;
grid-template-rows: auto 1fr;
min-width: 0;
border: 1px solid var(--start-line);
border-radius: 0.65rem;
background: var(--start-panel);
}
.stepCopy {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.65rem;
}
.stepCopy > span {
display: grid;
flex: none;
place-items: center;
width: 1.4rem;
height: 1.4rem;
border: 1px solid var(--start-line);
border-radius: 50%;
color: var(--start-muted);
font-family: var(--font-mono);
font-size: 0.62rem;
font-weight: 700;
}
.stepCopy > div {
display: grid;
min-width: 0;
}
.stepCopy strong {
font-size: 0.78rem;
}
.stepCopy small {
color: var(--start-muted);
font-size: 0.64rem;
line-height: 1.35;
}
.stepMedia {
display: grid;
place-items: center;
min-height: 9rem;
padding: 0.4rem;
border-top: 1px solid var(--start-line);
background: #202020;
}
.stepMedia img {
display: block;
width: 100%;
max-height: 12rem;
object-fit: contain;
}
.stepMedia img[data-fit="cover"] {
height: 100%;
object-fit: cover;
object-position: top;
}
.installPaths {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.8rem;
margin: 1rem 0 1.5rem;
}
.installPaths > .installPath {
position: relative;
display: grid;
gap: 0.45rem;
min-height: 10rem;
padding: 1.1rem;
border: 1px solid var(--start-line-strong);
border-radius: 0.9rem;
color: var(--start-ink);
background: color-mix(in oklch, var(--start-coral) 2.5%, var(--start-panel));
text-decoration: none;
transition:
border-color 160ms ease,
transform 160ms ease,
background-color 160ms ease;
}
.installPaths > .installPath:hover {
border-color: rgb(250 82 82 / 55%);
color: var(--start-ink);
background: color-mix(in oklch, var(--start-coral) 6%, var(--start-panel));
text-decoration: none;
transform: translateY(-2px);
}
.installPaths > .installPath > span {
color: var(--color-fd-primary);
font-family: var(--font-mono);
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.installPaths > .installPath strong {
font-size: 1rem;
}
.installPaths > .installPath small {
color: var(--start-muted);
line-height: 1.45;
}
.installPaths > .installPath > svg {
position: absolute;
right: 0.9rem;
bottom: 0.9rem;
color: var(--start-coral);
}
.installPaths > .recommendedPath {
border-color: var(--color-fd-primary);
background: color-mix(in oklch, var(--start-coral) 5%, var(--start-panel));
}
@media (max-width: 800px) {
.editorHeading {
align-items: start;
}
.editorSteps {
grid-template-columns: minmax(0, 1fr);
}
.stepMedia img {
max-height: 18rem;
}
.installPaths {
grid-template-columns: 1fr;
}
.installPaths > .installPath {
min-height: 8rem;
}
}
@media (prefers-reduced-motion: reduce) {
.installPaths > .installPath {
transition: none;
}
}
@@ -0,0 +1,141 @@
import Link from "next/link";
import { asMarkdown } from "fumadocs-core/server";
import type { ElementType } from "react";
import { IconArrowRight } from "@tabler/icons-react";
import addItem from "@site/docs/getting-started/img/manage-board-header-choose-item.png";
import moveItem from "@site/docs/getting-started/img/move-item.gif";
import resizeItem from "@site/docs/getting-started/img/resize-item.gif";
import { ConceptFlow } from "./concept-flow";
import styles from "./getting-started-overview.module.css";
const editingSteps = [
{
title: "Add",
description: "Choose an app, widget, or container.",
image: addItem,
alt: "Add item menu in board edit mode",
fit: "cover",
},
{
title: "Move",
description: "Drag the item to another grid position.",
image: moveItem,
alt: "Dragging an app tile on a board",
fit: "contain",
},
{
title: "Resize",
description: "Drag the resize handle to change its grid area.",
image: resizeItem,
alt: "Resizing an app tile on a board",
fit: "contain",
},
];
export function GettingStartedOverview() {
const isMarkdown = asMarkdown();
let BoardLink: ElementType = Link;
if (isMarkdown) BoardLink = "a";
return (
<div className={`${styles.overview} not-prose`}>
<section className={styles.conceptCard} aria-labelledby="getting-started-overview-title">
<div className={styles.intro}>
<h2 id="getting-started-overview-title">How Homarr works</h2>
<p>
Services expose data, integrations connect to them, widgets use the data, and boards arrange the widgets.
</p>
</div>
{!isMarkdown && <ConceptFlow />}
</section>
<section className={styles.editor} aria-labelledby="board-editor-title">
<div className={styles.editorHeading}>
<div>
<h3 id="board-editor-title">Board edit mode</h3>
<p>Add items, move them, and resize them on the grid. Each viewport can have its own layout.</p>
</div>
<BoardLink href="/docs/management/boards">
Board docs {!isMarkdown && <IconArrowRight size={15} aria-hidden="true" />}
</BoardLink>
</div>
<ol className={styles.editorSteps}>
{editingSteps.map((step, index) => (
<li className={styles.editorStep} key={step.title}>
<div className={styles.stepCopy}>
{!isMarkdown && <span>{index + 1}</span>}
<div>
<strong>{step.title}</strong> <small>{step.description}</small>
</div>
</div>
<div className={styles.stepMedia}>
<img src={step.image.src} alt={step.alt} data-fit={step.fit} loading="lazy" />
</div>
</li>
))}
</ol>
</section>
</div>
);
}
const installationPaths = [
{
eyebrow: "Recommended",
title: "Docker Compose",
description: "General-purpose deployment with a persistent appdata volume.",
href: "/docs/getting-started/installation/docker",
recommended: true,
},
{
eyebrow: "Kubernetes",
title: "Helm",
description: "Deploy Homarr into an existing cluster.",
href: "/docs/getting-started/installation/helm",
recommended: false,
},
{
eyebrow: "Platforms",
title: "NAS and hosting guides",
description: "Unraid, TrueNAS, Synology, Portainer, Proxmox, and more.",
href: "/docs/getting-started/installation",
recommended: false,
},
];
export function InstallationPaths() {
const isMarkdown = asMarkdown();
if (isMarkdown) {
return (
<ul>
{installationPaths.map((path) => (
<li key={path.href}>
<a href={path.href}>{path.title}</a> ({path.eyebrow}): {path.description}
</li>
))}
</ul>
);
}
return (
<div className={`${styles.installPaths} not-prose`}>
{installationPaths.map((path) => {
let className = styles.installPath;
if (path.recommended) className = `${className} ${styles.recommendedPath}`;
return (
<Link className={className} href={path.href} key={path.href}>
<span>{path.eyebrow}</span>
<strong>{path.title}</strong>
<small>{path.description}</small>
<IconArrowRight size={16} aria-hidden="true" />
</Link>
);
})}
</div>
);
}
@@ -1,12 +1,12 @@
import { ReactNode } from "react";
import { cn } from "@/lib/utils";
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>
);
return <div className={cn("mx-auto w-full max-w-6xl px-6 sm:px-8", className)}>{children}</div>;
};
@@ -0,0 +1,158 @@
.section {
padding-block: clamp(3.5rem, 6vw, 5rem);
background: var(--muted);
}
.heading {
max-width: 46rem;
margin-inline: auto;
text-align: center;
}
.heading h2 {
margin: 0;
font-size: clamp(2rem, 4vw, 3.25rem);
font-weight: 800;
line-height: 1.05;
letter-spacing: -0.04em;
text-wrap: balance;
}
.map {
position: relative;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: clamp(4rem, 10vw, 9rem);
max-width: 50rem;
min-height: 16rem;
margin: 3rem auto 0;
}
.connections {
position: absolute;
z-index: 0;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
fill: none;
stroke: var(--primary);
stroke-width: 2.5;
stroke-dasharray: 8 10;
stroke-linecap: round;
animation: integration-flow 12s linear infinite;
opacity: 0.72;
}
.serviceColumn {
position: relative;
z-index: 1;
display: grid;
gap: 1.5rem;
}
.service {
display: flex;
min-width: 8.5rem;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 0.75rem;
background: var(--card);
color: var(--foreground);
font-size: 0.8125rem;
font-weight: 650;
}
.leftColumn .service:last-child,
.leftColumn .service:first-child {
transform: translateX(1.5rem);
}
.rightColumn .service:last-child,
.rightColumn .service:first-child {
transform: translateX(-1.5rem);
}
.service img {
width: 2.5rem;
height: 2.5rem;
object-fit: contain;
}
.homarrNode {
position: relative;
z-index: 2;
display: grid;
justify-items: center;
gap: 0.75rem;
font-size: 0.9375rem;
}
.logoShell {
display: grid;
width: 6.5rem;
height: 6.5rem;
place-items: center;
border: 2px solid color-mix(in srgb, var(--primary) 45%, var(--border));
border-radius: 1.5rem;
background: var(--card);
box-shadow: 0 0 0 0.5rem color-mix(in srgb, var(--primary) 9%, transparent);
}
@keyframes integration-flow {
to {
stroke-dashoffset: -72;
}
}
@media (max-width: 640px) {
.map {
gap: 1.5rem;
min-height: 15rem;
margin-top: 2.5rem;
}
.serviceColumn {
gap: 1.5rem;
}
.service {
min-width: 0;
justify-content: center;
padding: 0.625rem;
}
.service span {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
.leftColumn .service:first-child,
.leftColumn .service:last-child,
.rightColumn .service:first-child,
.rightColumn .service:last-child {
transform: none;
}
.logoShell {
width: 4.75rem;
height: 4.75rem;
}
.logoShell img {
width: 3.25rem;
}
}
@media (prefers-reduced-motion: reduce) {
.connections {
animation: none;
}
}
@@ -1,147 +1,60 @@
import React, { useRef } from "react";
import { SectionContainer } from "@/components/pages/home/container/section-container";
import LeaderLine, { SocketType } from "leader-line-new";
import styles from "./dataflow.module.css";
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);
const services = {
left: [
["Radarr", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/radarr.svg"],
["Sonarr", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/sonarr.svg"],
["Lidarr", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/lidarr.svg"],
],
right: [
["SABnzbd", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/sabnzbd.svg"],
["Jellyfin", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/jellyfin.svg"],
["Plex", "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/plex.svg"],
],
} as const;
function ServiceColumn({ side }: { side: keyof typeof services }) {
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 className={`${styles.serviceColumn} ${side === "left" ? styles.leftColumn : styles.rightColumn}`}>
{services[side].map(([name, iconUrl]) => (
<div className={styles.service} key={name}>
<img src={iconUrl} alt="" width={46} height={46} />
<span>{name}</span>
</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;
export function HomeDataflow() {
return (
<section className={styles.section} aria-labelledby="integration-flow-title">
<SectionContainer>
<div className={styles.heading}>
<h2 id="integration-flow-title">No YAML configurations. Easy and quick to manage integrations.</h2>
</div>
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;
};
<div className={styles.map} aria-label="Services connected to Homarr">
<svg className={styles.connections} aria-hidden="true" viewBox="0 0 800 300" preserveAspectRatio="none">
<path d="M95 42 C250 42 245 150 400 150" />
<path d="M95 150 H400" />
<path d="M95 258 C250 258 245 150 400 150" />
<path d="M705 42 C550 42 555 150 400 150" />
<path d="M705 150 H400" />
<path d="M705 258 C550 258 555 150 400 150" />
</svg>
<ServiceColumn side="left" />
<div className={styles.homarrNode}>
<span className={styles.logoShell}>
<img src="/img/logo.svg" alt="" width={62} height={44} />
</span>
<strong>Homarr</strong>
</div>
<ServiceColumn side="right" />
</div>
</SectionContainer>
</section>
);
}
@@ -0,0 +1,108 @@
import {
IconArrowRight,
IconBook2,
IconBraces,
IconCopy,
IconPlugConnected,
IconSearch,
IconTools,
} from "@tabler/icons-react";
import Link from "next/link";
import { SectionContainer } from "@/components/pages/home/container/section-container";
const destinations = [
{
href: "/docs/getting-started",
icon: IconBook2,
title: "Install Homarr",
description: "Choose a platform and get your first instance running.",
},
{
href: "/docs/integrations",
icon: IconPlugConnected,
title: "Connect integrations",
description: "Add the services Homarr can monitor and control.",
},
{
href: "/docs/widgets",
icon: IconTools,
title: "Configure widgets",
description: "See available widgets and their configuration.",
},
{
href: "/api-reference",
icon: IconBraces,
title: "Explore the API",
description: "Use the generated reference for supported endpoints.",
},
] as const;
export function DocsGateway() {
return (
<section className="py-12 sm:py-16" aria-labelledby="docs-gateway-title">
<SectionContainer>
<div className="grid overflow-hidden border bg-fd-card lg:grid-cols-[0.9fr_1.1fr]">
<div className="flex flex-col justify-between border-b p-7 sm:p-10 lg:border-b-0 lg:border-r">
<div>
<h2 id="docs-gateway-title" className="m-0 text-3xl font-bold tracking-tight sm:text-4xl">
Documentation
</h2>
<p className="mb-0 mt-5 max-w-lg leading-7 text-fd-muted-foreground">
Choose a starting point, search the documentation, or use the Markdown and LLM-readable versions.
</p>
</div>
<div className="mt-10 grid gap-3 text-sm">
<div className="flex items-center gap-3 text-fd-muted-foreground">
<IconSearch aria-hidden="true" size={18} />
<span>Search all documentation</span>
</div>
<div className="flex items-center gap-3 text-fd-muted-foreground">
<IconCopy aria-hidden="true" size={18} />
<span>Copy or view each guide as Markdown</span>
</div>
<div className="flex flex-wrap gap-2 pt-2">
<a
className="rounded-md bg-fd-muted px-2.5 py-1 font-mono text-xs hover:text-fd-primary"
href="/llms.txt"
>
/llms.txt
</a>
<a
className="rounded-md bg-fd-muted px-2.5 py-1 font-mono text-xs hover:text-fd-primary"
href="/llms-full.txt"
>
/llms-full.txt
</a>
</div>
</div>
</div>
<nav aria-label="Documentation starting points">
{destinations.map(({ href, icon: Icon, title, description }) => (
<Link
key={href}
href={href}
className="group grid grid-cols-[2.5rem_1fr_auto] items-center gap-4 border-b p-5 text-fd-foreground transition-colors last:border-b-0 hover:bg-fd-muted/70 sm:p-6"
>
<span className="flex size-10 items-center justify-center rounded-md bg-fd-muted text-fd-primary">
<Icon aria-hidden="true" size={21} />
</span>
<span>
<strong className="block text-sm font-semibold">{title}</strong>
<span className="mt-1 block text-sm leading-5 text-fd-muted-foreground">{description}</span>
</span>
<IconArrowRight
aria-hidden="true"
className="text-fd-muted-foreground transition-transform group-hover:translate-x-1 group-hover:text-fd-primary"
size={19}
/>
</Link>
))}
</nav>
</div>
</SectionContainer>
</section>
);
}
@@ -1,26 +0,0 @@
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>
);
};
@@ -1,4 +1,3 @@
import React from "react";
import {
IconAdjustments,
IconDragDrop,
@@ -6,7 +5,7 @@ import {
IconKey,
IconLanguage,
IconPlug,
TablerIcon,
type TablerIcon,
} from "@tabler/icons-react";
interface Feature {
@@ -18,61 +17,58 @@ interface Feature {
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.",
title: "Build boards with drag and drop",
content: "Arrange apps and widgets with a mouse or a finger.",
},
{
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.",
title: "Thousands of built-in icons",
content: "Choose icons from the repositories included with Homarr.",
},
{
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.",
title: "Connect the services you run",
content: "See status, activity, and controls from supported apps directly on the dashboard.",
},
{
icon: IconKey,
title: "Authentication & Authorization built in",
content: "Support for credentials authentication, OIDC and LDAP. Complex system to manage permissions for users",
title: "Authentication and permissions included",
content: "Use credentials, OIDC, or LDAP and decide precisely what each user can see and manage.",
},
{
icon: IconLanguage,
title: "26 languages available",
content: "Accessible for users in many countries thanks to the community translation program",
title: "Available in 26 languages",
content: "Select one of 26 available interface languages.",
},
{
icon: IconAdjustments,
title: "Detailed settings for customization",
content: "Adjust apps and dashboards until you like them with helpful and easy to understand settings",
title: "Fine-grained customization",
content: "Configure boards, apps, and widgets from the interface.",
},
];
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>
<article className="grid grid-cols-[2.75rem_1fr] gap-4 border-t py-7 first:border-t-0 sm:first:border-t lg:[&:nth-child(-n+2)]:border-t-0 lg:[&:nth-child(odd)]:pr-8 lg:[&:nth-child(even)]:border-l lg:[&:nth-child(even)]:pl-8">
<div className="flex size-11 items-center justify-center rounded-lg bg-fd-primary/10 text-fd-primary">
<props.icon aria-hidden="true" size={24} stroke={1.8} />
</div>
</div>
<div>
<h2 className="m-0 text-lg font-semibold tracking-tight text-fd-foreground">{props.title}</h2>
<p className="mb-0 mt-2 text-sm leading-6 text-fd-muted-foreground">{props.content}</p>
</div>
</article>
);
}
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>
<section className="mb-20 mt-4 lg:mb-24" aria-label="Homarr features">
<div className="grid grid-cols-1 lg:grid-cols-2">
{featureList.map((props) => (
<FeatureComponent key={props.title} {...props} />
))}
</div>
</section>
);
}
@@ -1,22 +1,101 @@
"use client";
import { IconBrandDocker, IconCpu, IconDeviceDesktopAnalytics } from "@tabler/icons-react";
import styles from "../../../../pages/index.module.css";
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";
import { StockWidget } from "./widgets/stock-widget";
import { WeatherWidget } from "./widgets/weather-widget";
import { WidgetCard } from "./widgets/card";
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">
<div className={`argos-ignore hero-cards ${styles.heroCards}`}>
<StockWidget />
<AppWidget />
<AppWidget className="hidden 3xl:block" />
<WeatherWidget />
<DownloadsWidget />
<EntityStateWidget />
<AppWidget />
<AppWidget name="Plex" />
<AppWidget name="Jellyfin" />
<AppWidget name="Homeassistant" label="Home Assistant" />
<ClockWidget />
<SystemResourcesWidget />
<WeatherWidget />
<EntityStateWidget />
<AppWidget name="PiHole" label="Pi-hole" />
<AppWidget name="qBittorrent" />
<DownloadsWidget />
<DockerWidget />
<AppWidget name="Sonarr" />
<AppWidget name="Radarr" />
</div>
);
};
const heroLabels = {
systemResources: "System resources",
online: "Online",
cpu: "CPU",
memory: "Memory",
disk: "Disk",
docker: "Docker",
dockerSummary: "2 running · 1 failed",
healthy: "Healthy",
running: "Running",
exited: "Exited",
};
const SystemResourcesWidget = () => (
<WidgetCard width={2} className={styles.resourcesWidget}>
<div className={styles.widgetHeading}>
<span className={styles.widgetTitle}>
<IconDeviceDesktopAnalytics size={17} aria-hidden="true" />
{heroLabels.systemResources}
</span>
<span className={styles.onlineStatus}>{heroLabels.online}</span>
</div>
<ResourceMeter icon={<IconCpu size={15} aria-hidden="true" />} label={heroLabels.cpu} value={34} />
<ResourceMeter label={heroLabels.memory} value={62} />
<ResourceMeter label={heroLabels.disk} value={98} />
</WidgetCard>
);
const ResourceMeter = ({ icon, label, value }: { icon?: React.ReactNode; label: string; value: number }) => (
<div className={`${styles.resourceMeter} ${value >= 90 ? styles.resourceMeterCritical : ""}`}>
<div className={styles.resourceLabel}>
<span>
{icon}
{label}
</span>
<span>{value}%</span>
</div>
<progress className={styles.resourceTrack} aria-label={`${label} usage`} max={100} value={value} />
</div>
);
const DockerWidget = () => (
<WidgetCard width={2} className={styles.dockerWidget}>
<div className={styles.widgetHeading}>
<span className={styles.widgetTitle}>
<IconBrandDocker size={18} aria-hidden="true" />
{heroLabels.docker}
</span>
<span className={styles.dockerCount}>{heroLabels.dockerSummary}</span>
</div>
<div className={styles.containerList}>
{[
{ name: "immich", status: heroLabels.healthy, failed: false },
{ name: "jellyfin", status: heroLabels.running, failed: false },
{ name: "paperless", status: heroLabels.exited, failed: true },
].map(({ name, status, failed }) => (
<div className={`${styles.containerRow} ${failed ? styles.containerRowFailed : ""}`} key={name}>
<span className={styles.containerName}>{name}</span>
<span className={styles.containerStatus}>
<span className={`${styles.statusDot} ${failed ? styles.statusDotFailed : ""}`} aria-hidden="true" />
{status}
</span>
</div>
))}
</div>
</WidgetCard>
);
@@ -1,65 +1,49 @@
import Link from "@docusaurus/Link";
import { IconDownload, IconExternalLink } from "@tabler/icons-react";
import clsx from "clsx";
import Link from "@/components/mdx/link";
import styles from "../../../../pages/index.module.css";
import { LaunchMenu } from "./launch-menu";
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">
<header className={clsx(styles.heroBanner, styles.hero)}>
<div className={styles.heroContent}>
<div className={styles.heroCopy}>
<h1 className={styles.heroTitle}>
A simple, yet
<br />
powerful dashboard
<br />
for your server.
</h1>
<p className={"lg:text-2xl text-base"}>
<p className={styles.heroDescription}>
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>
<div className={styles.heroActions}>
<Link data-attr="Install button" className={styles.heroButton} to="/docs/getting-started">
<span>Install</span>
<IconDownload aria-hidden="true" size={20} />
</Link>
<Link
data-attr="Try demo button"
className={"button button--secondary button--lg rounded-3xl dark:border-zinc-600 dark:bg-zinc-800"}
className={styles.heroButton}
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>
<span>Try Demo</span>
<IconExternalLink aria-hidden="true" size={20} />
</Link>
<LaunchMenu />
</div>
</div>
<div className="flex-grow-1 w-full lg:block hidden hover-animation">
<section className={clsx(styles.heroPreview, "hover-animation")} aria-label="Example Homarr dashboard">
<HeroCards />
</div>
</section>
</div>
</header>
);
@@ -0,0 +1,101 @@
.trigger {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid var(--hero-button-border);
border-radius: 1.5rem;
background: var(--hero-button-bg);
color: var(--hero-button-fg);
font: inherit;
font-size: 1.125rem;
font-weight: 700;
line-height: 1.5;
cursor: pointer;
}
.label {
flex: 1;
padding: 0.625rem 1.25rem;
}
.chevron {
display: flex;
align-items: center;
justify-content: center;
align-self: stretch;
width: 2.75rem;
border-left: 1px solid var(--hero-button-border);
}
.trigger:hover,
.trigger[data-popup-open] {
background: var(--hero-button-hover-bg);
color: var(--hero-button-hover-fg);
}
.trigger:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 3px;
}
.positioner {
z-index: 100;
}
.popup {
width: 228px;
max-width: calc(100vw - 24px);
padding: 0.375rem;
border: 1px solid var(--border);
border-radius: 1rem;
background: var(--card);
color: var(--foreground);
box-shadow: 0 12px 32px rgb(0 0 0 / 16%);
outline: none;
}
.item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem;
border-radius: 0.625rem;
color: inherit;
font-size: 0.9375rem;
font-weight: 500;
line-height: 1.5;
text-decoration: none;
outline: none;
cursor: pointer;
}
.item:hover,
.item[data-highlighted] {
background: var(--accent);
color: inherit;
text-decoration: none;
}
.logo {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 34px;
height: 34px;
border: 1px solid var(--border);
border-radius: 0.5rem;
background: var(--card);
}
.logo img {
object-fit: contain;
}
.providerName {
flex: 1;
}
.externalIcon {
opacity: 0.55;
}
@@ -0,0 +1,45 @@
import { Menu } from "@base-ui/react/menu";
import { IconChevronDown, IconExternalLink } from "@tabler/icons-react";
import styles from "./launch-menu.module.css";
const providers = [
{ name: "PikaPods", logo: "pikapods", href: "https://www.pikapods.com/pods?run=homarr" },
{ name: "Railway", logo: "railway", href: "https://railway.com/deploy/_c4Kr9?referralCode=vishify" },
{ name: "Hostinger", logo: "hostinger", href: "https://www.hostg.xyz/aff_c?offer_id=815&aff_id=244277&url_id=6861" },
];
export function LaunchMenu() {
const logoBase = "/img/pictures/partner/";
return (
<Menu.Root modal={false}>
<Menu.Trigger className={styles.trigger}>
<span className={styles.label}>Launch</span>
<span className={styles.chevron} aria-hidden="true">
<IconChevronDown size={18} />
</span>
</Menu.Trigger>
<Menu.Portal>
<Menu.Positioner className={styles.positioner} sideOffset={8} align="end" collisionPadding={12}>
<Menu.Popup className={styles.popup} aria-label="Hosting providers">
{providers.map((provider) => (
<Menu.Item
key={provider.name}
className={styles.item}
nativeButton={false}
render={<a href={provider.href} aria-label={provider.name} />}
data-attr={`Redirect to ${provider.name}`}
>
<span className={styles.logo}>
<img src={`${logoBase}${provider.logo}.png`} alt="" width={24} height={24} />
</span>
<span className={styles.providerName}>{provider.name}</span>
<IconExternalLink className={styles.externalIcon} size={16} aria-hidden="true" />
</Menu.Item>
))}
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
);
}
@@ -1,20 +1,21 @@
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)];
interface AppWidgetProps extends CommonWidgetProps {
name: string;
label?: string;
}
export const AppWidget = ({ name, label, className }: AppWidgetProps) => {
const app = supportedIntegrations.find((integration) => integration.name === name);
if (!app) return null;
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 width={1} className={clsx("app-card text-center", className)}>
<span className="absolute top-2 inset-x-1 text-sm font-bold leading-4">{label ?? app.name}</span>
<img src={app.iconUrl} className="absolute bottom-0 size-24 object-contain scale-[0.6]" alt="" />
<div className="absolute bottom-3 right-3 rounded-full bg-green-500 w-2 h-2" aria-hidden="true" />
</WidgetCard>
);
};
@@ -1,5 +1,6 @@
import clsx from "clsx";
import { ReactNode } from "react";
import styles from "../../../../../pages/index.module.css";
export interface CommonWidgetProps {
className?: string;
@@ -16,15 +17,13 @@ export const WidgetCard = ({ width, children, className, onClick }: WidgetCardPr
return (
<Component
className={clsx(
"rounded-xl flex flex-col justify-center items-center p-2 relative border-none bg-[var(--ifm-navbar-background-color)]",
styles.widgetCard,
"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%)",
}}
style={{ width: (128 * width + (width === 2 ? 16 : 0)) * 0.9, height: 128 }}
type={onClick ? "button" : undefined}
onClick={onClick}
>
{children}
@@ -1,12 +1,26 @@
import dayjs from "dayjs";
import { CommonWidgetProps, WidgetCard } from "./card";
import clsx from "clsx";
import { useEffect, useState } from "react";
import { CommonWidgetProps, WidgetCard } from "./card";
export const ClockWidget = ({ className }: CommonWidgetProps) => {
const [now, setNow] = useState<Date | null>(null);
useEffect(() => {
const update = () => setNow(new Date());
update();
const timer = window.setInterval(update, 1000);
return () => window.clearInterval(timer);
}, []);
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>
<span className="text-xl font-bold tabular-nums">
{now?.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", hour12: false }) ?? "--:--"}
</span>
<span className="text-xs">
{now?.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "2-digit" }) ?? "\u00a0"}
</span>
</WidgetCard>
);
};
@@ -1,26 +1,41 @@
import { getRndInteger } from "@site/src/tools/math";
"use client";
import { useEffect, useState } from "react";
import { CommonWidgetProps, WidgetCard } from "./card";
import clsx from "clsx";
import { generateSlug } from "random-word-slugs";
import styles from "../../../../../pages/index.module.css";
const downloadQueue = ["family-photos.zip", "linux-isos.mkv", "server-backup.zip", "holiday-video.mp4"];
const downloadLabels = {
file: "File",
progress: "Progress",
};
export const DownloadsWidget = ({ className }: CommonWidgetProps) => {
const [downloads, setDownloads] = useState<Download[]>([]);
const [downloads, setDownloads] = useState<Download[]>([
{ filename: "photos-backup.zip", progress: 72 },
{ filename: "media-library.mkv", progress: 38 },
]);
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));
});
const progressedDownloads = filteredPrev.map((download, index) => ({
...download,
progress: Math.min(100, download.progress + (index < 2 ? 8 : 4)),
}));
if (filteredPrev.length < 3 && filteredPrev.length === prev.length) {
filteredPrev.push(generateRandomDownload());
if (progressedDownloads.length < 3 && progressedDownloads.length === prev.length) {
const availableDownloads = downloadQueue.filter(
(filename) => !progressedDownloads.some((download) => download.filename === filename),
);
const filename = availableDownloads[0];
if (filename) progressedDownloads.push({ filename, progress: 0 });
}
return [...filteredPrev];
return progressedDownloads;
});
}, 500);
@@ -34,19 +49,22 @@ export const DownloadsWidget = ({ className }: CommonWidgetProps) => {
<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>
<th className="border-none px-2 py-1 text-start inline-block w-1/2">{downloadLabels.file}</th>
<th className="border-none px-2 py-1 text-start inline-block w-1/2">{downloadLabels.progress}</th>
</tr>
</thead>
<tbody>
{downloads.map((file: Download, index: number) => {
{downloads.map((file: Download) => {
return (
<tr key={index}>
<tr key={file.filename}>
<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 className="border-none p-2 w-full" aria-label={`${file.progress}% complete`}>
<progress
className={styles.downloadProgress}
value={file.progress}
max={100}
aria-label={`${file.filename} download progress`}
/>
</td>
</tr>
);
@@ -61,15 +79,3 @@ 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,
};
};
@@ -1,4 +1,6 @@
import { useColorMode } from "@docusaurus/theme-common";
"use client";
import { useColorMode } from "@/hooks/use-color-mode";
import { CommonWidgetProps, WidgetCard } from "./card";
import clsx from "clsx";
@@ -11,8 +13,9 @@ export const EntityStateWidget = ({ className }: CommonWidgetProps) => {
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>
<span className="text-sm font-bold">Lights</span>
<span className="text-sm dark:hidden">ON</span>
<span className="hidden text-sm dark:inline">OFF</span>
</WidgetCard>
);
};
@@ -4,8 +4,13 @@ import clsx from "clsx";
import { useEffect, useState } from "react";
import { CommonWidgetProps, WidgetCard } from "./card";
const initialStockTrend = Array.from({ length: 100 }, (_, x) => ({
x,
y: Number((100 + x * 0.16 + Math.sin(x / 7) * 4).toFixed(2)),
}));
export const StockWidget = ({ className }: CommonWidgetProps) => {
const [stockTrend, setStockTrend] = useState<{ x: number; y: number }[]>(generateStockTrend());
const [stockTrend, setStockTrend] = useState<{ x: number; y: number }[]>(initialStockTrend);
useEffect(() => {
const interval = setInterval(() => {
@@ -27,6 +32,7 @@ export const StockWidget = ({ className }: CommonWidgetProps) => {
HOMR
</div>
<Line
ariaLabel="Simulated HOMR stock price trend"
height={128}
width={244}
colors={[upwardTrend ? "green" : "red"]}
@@ -1,4 +1,4 @@
import { IconCloud, IconWind, IconArrowUpRight, IconArrowDownRight } from "@tabler/icons-react";
import { IconCloud, IconCloudRain, IconSun } from "@tabler/icons-react";
import { CommonWidgetProps, WidgetCard } from "./card";
import clsx from "clsx";
@@ -6,21 +6,27 @@ 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>
<IconSun size={22} />
<div className="flex flex-col leading-none">
<span className="text-xl font-bold">18.4°C</span>
<span className="text-xs text-muted-foreground">Paris</span>
</div>
<div className="flex justify-center items-center">
<IconArrowDownRight size={14} />
<span className="text-sm">6.4°C</span>
</div>
<div className="flex justify-evenly items-center w-full">
<div className="flex flex-col items-center text-xs">
<span>Mon</span>
<IconSun size={16} />
<span>21°</span>
</div>
<div className="flex flex-col items-center text-xs">
<span>Tue</span>
<IconCloud size={16} />
<span>19°</span>
</div>
<div className="flex flex-col items-center text-xs">
<span>Wed</span>
<IconCloudRain size={16} />
<span>16°</span>
</div>
</div>
</WidgetCard>
@@ -1,20 +0,0 @@
@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;
}
}
@@ -1,68 +1,54 @@
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";
import { integrationKinds } from "@homarr/definitions/integration";
import { IconArrowRight } from "@tabler/icons-react";
import Link from "next/link";
const countIconGroups = 3;
const animationDurationInSeconds = 12;
import { SectionContainer } from "@/components/pages/home/container/section-container";
import { supportedIntegrations } from "@/constants/supported-integrations";
export const AvailableIntegrations = () => {
const arrayInChunks = splitToNChunks(
supportedIntegrations.map((integration) => integration.iconUrl),
countIconGroups,
);
const featuredIntegrations = supportedIntegrations.filter(({ name }) => name !== "Homarr").slice(0, 11);
const integrationCount = integrationKinds.length;
const remainingCount = integrationCount - featuredIntegrations.length;
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.
<SectionContainer className="my-16 sm:my-20">
<section className="border bg-fd-card p-6 sm:p-8" aria-labelledby="integrations-title">
<div className="grid items-center gap-8 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
<div>
<h2 id="integrations-title" className="m-0 text-3xl font-bold tracking-tight sm:text-4xl">
{integrationCount}+ integrations built in
</h2>
<p className="mb-0 mt-4 leading-7 text-fd-muted-foreground">
Browse supported integrations and their setup instructions.
</p>
<Link
className="mt-5 inline-flex w-fit items-center gap-2 text-sm font-semibold text-fd-primary hover:underline"
href="/docs/integrations"
>
Browse supported integrations
<IconArrowRight aria-hidden="true" size={18} />
</Link>
</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 className="grid grid-cols-6 gap-2" aria-label="Examples of supported integrations">
{featuredIntegrations.map(({ name, iconUrl }) => (
<div
key={name}
className="flex aspect-square items-center justify-center border bg-fd-muted/35 p-2 sm:p-3"
title={name}
>
<img className="size-7 object-contain sm:size-10" src={iconUrl} alt={name} width={40} height={40} />
</div>
))}
<Link
href="/docs/integrations"
className="flex aspect-square items-center justify-center border bg-fd-muted/35 p-2 text-lg font-semibold text-fd-primary hover:bg-fd-accent sm:p-3 sm:text-xl"
aria-label={`${remainingCount} more integrations`}
>
+{remainingCount}
</Link>
</div>
</div>
</div>
</section>
</SectionContainer>
);
};
@@ -0,0 +1,30 @@
import homarrV2SocialImage from "@site/public/media/homarr-home.webp";
import { SectionContainer } from "@/components/pages/home/container/section-container";
export const MotionReelShowcase = () => {
return (
<section className="my-20 sm:my-24" aria-labelledby="motion-reel-title">
<SectionContainer className="relative max-w-5xl">
<div className="mx-auto mb-8 max-w-2xl text-center">
<h2 id="motion-reel-title" className="m-0 text-3xl font-bold tracking-tight sm:text-5xl">
Homarr showreel
</h2>
</div>
<div className="aspect-video overflow-hidden rounded-2xl border-4 border-fd-primary bg-fd-muted">
<video
className="h-full w-full"
src="https://media.homarr.dev/videos/home/homarr-motion-reel.mp4"
poster={homarrV2SocialImage.src}
aria-label="Homarr 2.0 motion reel showing dashboards and integrations"
controls
playsInline
preload="none"
>
<track kind="captions" src="/media/homepage-motion-reel.vtt" srcLang="en" label="English" />
</video>
</div>
</SectionContainer>
</section>
);
};
@@ -1,4 +1,4 @@
import React from "react";
import { IconArrowUpRight, IconBrandDiscord } from "@tabler/icons-react";
const reviews = [
{
@@ -7,103 +7,88 @@ const reviews = [
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/",
source: "Community setup",
},
{
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",
source: "Video review",
},
{
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",
source: "r/selfhosted",
},
{
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/",
source: "r/unRAID",
},
{
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",
},
];
] as const;
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>
<section className="my-20 sm:my-24" aria-labelledby="community-title">
<div className="mx-auto max-w-2xl text-center">
<h2 id="community-title" className="m-0 text-3xl font-bold tracking-tight sm:text-5xl">
Community
</h2>
<p className="mb-0 mt-5 text-base leading-7 text-fd-muted-foreground sm:text-lg">
Get help, report issues, and contribute through the Homarr community.
</p>
<a
className="mt-6 inline-flex items-center gap-2 text-sm font-semibold text-fd-primary hover:underline"
href="https://discord.com/invite/aCsmEV5RgA"
target="_blank"
rel="noopener noreferrer"
>
<IconBrandDiscord aria-hidden="true" size={19} />
Join Discord
</a>
</div>
<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
<div className="mt-10 grid grid-cols-1 border sm:grid-cols-2">
{reviews.map((review, index) => (
<a
key={review.link}
className="bg-gray-100 hover:bg-gray-200 dark:bg-zinc-800 dark:hover:bg-zinc-700 rounded-2xl"
aria-label={`Read ${review.username} on ${review.source}`}
className={`group block p-6 text-fd-foreground transition-colors hover:bg-fd-muted/60 sm:p-8 ${
index > 0 ? "border-t sm:border-t-0" : ""
} ${index % 2 === 1 ? "sm:border-l" : ""} ${index >= 2 ? "sm:border-t" : ""}`}
href={review.link}
target="_blank"
rel="noopener noreferrer"
>
<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>
<blockquote className="m-0">
<p className="m-0 text-base leading-7">“{review.fullText}”</p>
<footer className="mt-6 flex items-center gap-3">
<img
className="size-11 rounded-full bg-fd-muted object-cover"
src={review.profilePicture}
alt=""
width={44}
height={44}
/>
<span className="min-w-0 flex-1">
<cite className="block not-italic text-sm font-semibold">{review.username}</cite>
<span className="block text-xs text-fd-muted-foreground">{review.source}</span>
</span>
<IconArrowUpRight
aria-hidden="true"
className="text-fd-muted-foreground transition-transform group-hover:-translate-y-0.5 group-hover:translate-x-0.5 group-hover:text-fd-primary"
size={18}
/>
</footer>
</blockquote>
</a>
))}
</div>
</div>
</section>
);
}
@@ -4,8 +4,6 @@
margin-bottom: 50px;
}
.communityFeedbackPlaceholderImage {
display: block;
margin: 0 auto;
@@ -14,5 +12,3 @@
.communityFeedbackPlaceholderText {
text-align: center;
}
@@ -1,8 +1,13 @@
"use client";
import type React from "react";
import CodeBlock from "@theme/CodeBlock";
import Admonition from "@theme/Admonition";
import { IconRefresh } from "@tabler/icons-react";
import { DynamicCodeBlock } from "fumadocs-ui/components/dynamic-codeblock";
import { useEffect, useState } from "react";
import Admonition from "@/components/mdx/admonition";
import { Button } from "@/components/ui/button";
const generateRandomHex = (length = 64): string => {
const array = new Uint8Array(length / 2);
crypto.getRandomValues(array);
@@ -24,8 +29,9 @@ export const DockerInstallSnippet: React.FC = () => {
<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"}>
{`#---------------------------------------------------------------------#
<DynamicCodeBlock
lang="yaml"
code={`#---------------------------------------------------------------------#
# Homarr - A simple, yet powerful dashboard for your server. #
#---------------------------------------------------------------------#
services:
@@ -41,14 +47,16 @@ services:
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}>
/>
<Admonition type="info" title="Encryption key">
<p>
The <code>SECRET_ENCRYPTION_KEY</code> above is randomly generated by your browser. You can also generate one
with <code>openssl rand -hex 32</code>.
</p>
<Button type="button" variant="outline" size="sm" onClick={generateNewHex}>
<IconRefresh aria-hidden="true" />
Refresh key
</button>
</Button>
</Admonition>
</div>
);
+17
View File
@@ -0,0 +1,17 @@
"use client";
import { RootProvider } from "fumadocs-ui/provider/next";
import { lazy, type ReactNode } from "react";
const Search = lazy(() => import("./search"));
export function Provider({ children }: { children: ReactNode }) {
return (
<RootProvider
search={{ SearchDialog: Search, preload: false }}
theme={{ attribute: ["class", "data-theme"], defaultTheme: "system", enableSystem: true }}
>
{children}
</RootProvider>
);
}
+24
View File
@@ -0,0 +1,24 @@
"use client";
import { useEffect, useState } from "react";
import Search from "./search";
export function SearchPage() {
const [query, setQuery] = useState<string>();
const [open, setOpen] = useState(true);
useEffect(() => {
setQuery(new URLSearchParams(window.location.search).get("q") ?? "");
}, []);
return (
<main className="mx-auto w-full max-w-4xl px-6 py-12">
<h1 className="text-3xl font-bold">Search documentation</h1>
<button type="button" className="mt-6 underline underline-offset-4" onClick={() => setOpen(true)}>
Open search
</button>
{query !== undefined && <Search open={open} onOpenChange={setOpen} initialSearch={query} />}
</main>
);
}
+82
View File
@@ -0,0 +1,82 @@
"use client";
import { useDocsSearch } from "fumadocs-core/search/client";
import { staticClient } from "fumadocs-core/search/client/orama-static";
import {
SearchDialog,
SearchDialogClose,
SearchDialogContent,
SearchDialogHeader,
SearchDialogIcon,
SearchDialogInput,
SearchDialogList,
SearchDialogOverlay,
type SharedProps,
} from "fumadocs-ui/components/dialog/search";
import { useEffect, useRef } from "react";
import { track } from "@/lib/analytics";
export default function Search({ initialSearch, ...props }: SharedProps & { initialSearch?: string }) {
const { search, setSearch, query } = useDocsSearch({
client: staticClient(),
});
const { open } = props;
const lastTracked = useRef("");
useEffect(() => {
if (initialSearch !== undefined) setSearch(initialSearch);
}, [initialSearch, setSearch]);
useEffect(() => {
if (!open) lastTracked.current = "";
}, [open]);
useEffect(() => {
if (!open || query.isLoading || query.error || !Array.isArray(query.data) || query.data.length > 0) return;
const trimmed = search.trim();
if (!trimmed || trimmed === lastTracked.current) return;
const timeout = setTimeout(() => {
lastTracked.current = trimmed;
track("Search No Results", { query: trimmed });
}, 900);
return () => clearTimeout(timeout);
}, [open, search, query.data, query.isLoading, query.error]);
return (
<SearchDialog search={search} onSearchChange={setSearch} isLoading={query.isLoading} {...props}>
<SearchDialogOverlay />
<SearchDialogContent>
<SearchDialogHeader>
<SearchDialogIcon />
<SearchDialogInput aria-label="Search documentation" />
<SearchDialogClose />
</SearchDialogHeader>
{query.error ? (
<div role="alert" className="space-y-3 px-4 py-6 text-sm">
<p>Search could not load. Reload the page to try again, or browse the documentation.</p>
<div className="flex gap-4">
<button type="button" className="underline underline-offset-4" onClick={() => window.location.reload()}>
Reload page
</button>
<a className="underline underline-offset-4" href="/docs/">
Browse documentation
</a>
</div>
</div>
) : (
<>
<output className="sr-only">
{query.isLoading
? "Searching documentation"
: Array.isArray(query.data)
? `${query.data.length} search results`
: ""}
</output>
<SearchDialogList items={query.data !== "empty" ? query.data : null} />
</>
)}
</SearchDialogContent>
</SearchDialog>
);
}
+63
View File
@@ -0,0 +1,63 @@
import Image from "next/image";
import Link from "next/link";
export function SiteFooter() {
return (
<footer className="border-t bg-fd-muted/50">
<div className="mx-auto grid w-full max-w-6xl gap-10 px-6 py-12 sm:grid-cols-3 sm:px-8">
<div>
<h2 className="text-sm font-semibold">Documentation</h2>
<div className="mt-3 grid gap-2 text-sm text-fd-muted-foreground">
<Link className="hover:text-fd-foreground" href="/docs/getting-started">
Installation
</Link>
<Link className="hover:text-fd-foreground" href="/docs">
Browse all docs
</Link>
</div>
</div>
<div>
<h2 className="text-sm font-semibold">Community</h2>
<div className="mt-3 grid gap-2 text-sm text-fd-muted-foreground">
<a className="hover:text-fd-foreground" href="https://discord.com/invite/aCsmEV5RgA">
Discord
</a>
<a className="hover:text-fd-foreground" href="https://www.reddit.com/r/homarr/">
Reddit
</a>
<a className="hover:text-fd-foreground" href="https://github.com/homarr-labs/homarr">
GitHub
</a>
<a className="hover:text-fd-foreground" href="https://opencollective.com/homarr">
Donate
</a>
</div>
</div>
<div>
<h2 className="text-sm font-semibold">More</h2>
<div className="mt-3 grid gap-2 text-sm text-fd-muted-foreground">
<Link className="hover:text-fd-foreground" href="/workshop">
Workshop
</Link>
<Link className="hover:text-fd-foreground" href="/blog">
Blog
</Link>
<Link className="hover:text-fd-foreground" href="/about-us">
About us
</Link>
</div>
</div>
<div className="flex flex-col items-center gap-3 text-center sm:col-span-3">
<Image src="/img/logo.svg" alt="Homarr" width={58} height={40} />
<p className="text-sm font-medium">Your dashboard for the services you run.</p>
<p className="text-xs text-fd-muted-foreground">
© 2026 Homarr ·{" "}
<Link className="hover:text-fd-foreground" href="/docs/community/license">
License
</Link>
</p>
</div>
</div>
</footer>
);
}
@@ -0,0 +1,14 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export function StaticRedirect({ href, label }: { href: string; label: string }) {
const router = useRouter();
useEffect(() => {
router.replace(`${href}${window.location.search}${window.location.hash}`);
}, [href, router]);
return <a href={href}>{label}</a>;
}
@@ -1,20 +0,0 @@
"use client";
import type { AnyApiReferenceConfiguration } from "@scalar/types/api-reference";
import { ApiReferenceReact } from "@scalar/api-reference-react";
import { useColorMode } from "@docusaurus/theme-common";
import "@scalar/api-reference-react/style.css";
export function ThemedApiReference({ configuration }: { configuration: AnyApiReferenceConfiguration }) {
const { colorMode } = useColorMode();
return (
<ApiReferenceReact
configuration={{
...configuration,
forceDarkModeState: colorMode === "dark" ? "dark" : "light",
}}
/>
);
}
@@ -0,0 +1,158 @@
"use client";
import * as React from "react";
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />;
}
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
}
function AlertDialogOverlay({ className, ...props }: AlertDialogPrimitive.Backdrop.Props) {
return (
<AlertDialogPrimitive.Backdrop
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className,
)}
{...props}
/>
);
}
function AlertDialogContent({
className,
size = "default",
...props
}: AlertDialogPrimitive.Popup.Props & {
size?: "default" | "sm";
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Popup
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className,
)}
{...props}
/>
);
}
function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function AlertDialogMedia({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className,
)}
{...props}
/>
);
}
function AlertDialogTitle({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className,
)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className,
)}
{...props}
/>
);
}
function AlertDialogAction({ className, ...props }: React.ComponentProps<typeof Button>) {
return <Button data-slot="alert-dialog-action" className={cn(className)} {...props} />;
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: AlertDialogPrimitive.Close.Props & Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<AlertDialogPrimitive.Close
data-slot="alert-dialog-cancel"
className={cn(className)}
render={<Button variant={variant} size={size} />}
{...props}
/>
);
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
};
+40
View File
@@ -0,0 +1,40 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-3 [&>svg]:mt-0.5 [&>svg]:size-4",
{
variants: {
variant: {
default: "border-border bg-card text-card-foreground",
success:
"border-emerald-600/30 bg-emerald-500/10 text-emerald-800 dark:border-emerald-400/30 dark:text-emerald-300 [&_[data-slot=alert-description]]:text-emerald-800/85 dark:[&_[data-slot=alert-description]]:text-emerald-300/85",
destructive:
"border-destructive/30 bg-destructive/10 text-destructive [&_[data-slot=alert-description]]:text-destructive/85",
},
},
defaultVariants: { variant: "default" },
},
);
function Alert({ className, variant, ...props }: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return <div role="alert" data-slot="alert" className={cn(alertVariants({ variant }), className)} {...props} />;
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="alert-title" className={cn("col-start-2 font-medium leading-none", className)} {...props} />;
}
function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn("col-start-2 text-sm leading-relaxed text-muted-foreground", className)}
{...props}
/>
);
}
export { Alert, AlertDescription, AlertTitle };
+41
View File
@@ -0,0 +1,41 @@
"use client";
import * as React from "react";
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar";
import { cn } from "@/lib/utils";
function Avatar({ className, ...props }: AvatarPrimitive.Root.Props) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn("relative flex size-8 shrink-0 overflow-hidden rounded-full", className)}
{...props}
/>
);
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full object-cover", className)}
{...props}
/>
);
}
function AvatarFallback({ className, ...props }: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground",
className,
)}
{...props}
/>
);
}
export { Avatar, AvatarFallback, AvatarImage };
+46 -10
View File
@@ -1,13 +1,49 @@
import { ReactNode } from "react";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority";
interface BadgeProps {
children: ReactNode;
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props,
),
render,
state: {
slot: "badge",
variant,
},
});
}
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>
);
};
export { Badge, badgeVariants };
+50
View File
@@ -0,0 +1,50 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return <ButtonPrimitive data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />;
}
export { Button, buttonVariants };
+70
View File
@@ -0,0 +1,70 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, size = "default", ...props }: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex break-inside-avoid flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-description" className={cn("text-sm text-muted-foreground", className)} {...props} />;
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-content" className={cn("px-(--card-spacing)", className)} {...props} />;
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center rounded-b-xl border-t border-border bg-muted/50 p-(--card-spacing)", className)}
{...props}
/>
);
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
+134
View File
@@ -0,0 +1,134 @@
"use client";
import * as React from "react";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { IconX } from "@tabler/icons-react";
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm" />}
>
<IconX />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="dialog-header" className={cn("flex flex-col gap-2", className)} {...props} />;
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className,
)}
{...props}
>
{children}
{showCloseButton && <DialogPrimitive.Close render={<Button variant="outline" />}>Close</DialogPrimitive.Close>}
</div>
);
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("font-heading text-base leading-none font-medium", className)}
{...props}
/>
);
}
function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className,
)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
@@ -0,0 +1,252 @@
import * as React from "react";
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
import { cn } from "@/lib/utils";
import { IconChevronRight, IconCheck } from "@tabler/icons-react";
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props & Pick<MenuPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn(
"z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
);
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn("px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7", className)}
{...props}
/>
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className,
)}
{...props}
/>
);
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<IconChevronRight className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
);
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn(
"w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<IconCheck />
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return <MenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean;
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<IconCheck />
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
);
}
function DropdownMenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};
+87
View File
@@ -0,0 +1,87 @@
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance",
className,
)}
{...props}
/>
);
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="empty-header" className={cn("flex max-w-sm flex-col items-center gap-2", className)} {...props} />
);
}
const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
variant: "default",
},
},
);
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
);
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn("font-heading text-sm font-medium tracking-tight", className)}
{...props}
/>
);
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className,
)}
{...props}
/>
);
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn("flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance", className)}
{...props}
/>
);
}
export { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia };
+218
View File
@@ -0,0 +1,218 @@
"use client";
import { useMemo } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className,
)}
{...props}
/>
);
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn("mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base", className)}
{...props}
/>
);
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
className,
)}
{...props}
/>
);
}
const fieldVariants = cva("group/field flex w-full gap-2 data-[invalid=true]:text-destructive", {
variants: {
orientation: {
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
horizontal:
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
responsive:
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
},
},
defaultVariants: {
orientation: "vertical",
},
});
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
);
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn("group/field-content flex flex-1 flex-col gap-0.5 leading-snug", className)}
{...props}
/>
);
}
function FieldLabel({ className, ...props }: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className,
)}
{...props}
/>
);
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
className,
)}
{...props}
/>
);
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"last:mt-0 nth-last-2:-mt-1",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className,
)}
{...props}
/>
);
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode;
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn("relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2", className)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
);
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>;
}) {
const content = useMemo(() => {
if (children) {
return children;
}
if (!errors?.length) {
return null;
}
const uniqueErrors = [...new Map(errors.map((error) => [error?.message, error])).values()];
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message;
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map((error, index) => error?.message && <li key={index}>{error.message}</li>)}
</ul>
);
}, [children, errors]);
if (!content) {
return null;
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)}
{...props}
>
{content}
</div>
);
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
};
@@ -0,0 +1,175 @@
.connector {
--flow-accent: var(--color-fd-primary);
--flow-coral: #f17865;
--flow-head: var(--diagram-ink, var(--color-fd-foreground));
--flow-line: var(--diagram-line, var(--color-fd-border));
--flow-rail: color-mix(in srgb, var(--flow-line) 68%, var(--flow-head));
position: relative;
display: grid;
align-content: center;
justify-items: center;
gap: 0.22rem;
min-width: 0;
color: var(--flow-head);
}
.label {
z-index: 1;
max-width: 100%;
padding: 0.17rem 0.36rem;
border: 1px solid color-mix(in srgb, var(--flow-accent) 32%, var(--flow-line));
border-radius: 999px;
color: var(--diagram-muted, var(--color-fd-muted-foreground));
background: var(--diagram-panel, var(--color-fd-card));
font-family: var(--font-mono);
font-size: 0.5rem;
font-weight: 650;
line-height: 1;
text-align: center;
white-space: nowrap;
}
.track {
position: relative;
overflow: hidden;
display: block;
width: calc(100% + 0.5rem);
height: 1.1rem;
margin-inline: -0.25rem;
}
.rail {
position: absolute;
top: 50%;
right: 0.18rem;
left: 0.18rem;
height: 2px;
background: var(--flow-rail);
transform: translateY(-50%);
}
.beam {
position: absolute;
top: calc(50% - 1.5px);
left: 0.23rem;
width: 48%;
height: 3px;
border-radius: 999px;
background: linear-gradient(90deg, transparent, var(--flow-coral) 34%, var(--flow-accent) 68%, transparent);
filter: drop-shadow(0 0 0.28rem color-mix(in srgb, var(--flow-accent) 48%, transparent));
animation: flow-beam-horizontal 2.4s cubic-bezier(0.4, 0, 0.2, 1) infinite;
animation-delay: var(--flow-delay);
}
.vertical {
min-height: 3rem;
}
.vertical .track {
width: 1.1rem;
height: 2.6rem;
margin-inline: 0;
}
.vertical .rail {
top: 0.16rem;
right: auto;
bottom: 0.16rem;
left: 50%;
width: 2px;
height: auto;
transform: translateX(-50%);
}
.vertical .beam {
top: 0.23rem;
left: calc(50% - 1.5px);
width: 3px;
height: 48%;
background: linear-gradient(180deg, transparent, var(--flow-coral) 46%, var(--flow-accent));
animation-name: flow-beam-vertical;
}
.visuallyHidden {
position: absolute;
overflow: hidden;
width: 1px;
height: 1px;
padding: 0;
border: 0;
margin: -1px;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@keyframes flow-beam-horizontal {
0% {
opacity: 0;
transform: translateX(-35%);
}
18%,
72% {
opacity: 1;
}
100% {
opacity: 0;
transform: translateX(155%);
}
}
@keyframes flow-beam-vertical {
0% {
opacity: 0;
transform: translateY(-35%);
}
18%,
72% {
opacity: 1;
}
100% {
opacity: 0;
transform: translateY(155%);
}
}
@media (max-width: 900px) {
.connector:not(.vertical) {
min-height: 3rem;
}
.connector:not(.vertical) .track {
width: 1.1rem;
height: 2.6rem;
margin-inline: 0;
}
.connector:not(.vertical) .rail {
top: 0.16rem;
right: auto;
bottom: 0.16rem;
left: 50%;
width: 2px;
height: auto;
transform: translateX(-50%);
}
.connector:not(.vertical) .beam {
top: 0.23rem;
left: calc(50% - 1.5px);
width: 3px;
height: 48%;
background: linear-gradient(180deg, transparent, var(--flow-coral) 46%, var(--flow-accent));
animation-name: flow-beam-vertical;
}
}
@media (prefers-reduced-motion: reduce) {
.beam {
opacity: 1;
animation: none;
}
}
@@ -0,0 +1,28 @@
import type { CSSProperties } from "react";
import styles from "./flow-connector.module.css";
type FlowConnectorProps = {
label?: string;
animationDelay?: number;
orientation?: "responsive" | "vertical";
};
export function FlowConnector({ label, animationDelay = 0, orientation = "responsive" }: FlowConnectorProps) {
let className = styles.connector;
if (orientation === "vertical") className = `${className} ${styles.vertical}`;
const style = { "--flow-delay": `${animationDelay}ms` } as CSSProperties;
let accessibleLabel = <span className={styles.visuallyHidden}>then</span>;
if (label) accessibleLabel = <small className={styles.label}>{label}</small>;
return (
<span className={className} style={style}>
{accessibleLabel}
<span className={styles.track} aria-hidden="true">
<span className={styles.rail} />
<span className={styles.beam} />
</span>
</span>
);
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { ReactNode } from "react";
import type { ReactNode } from "react";
import { Badge } from "../ui/badge";
interface DocsHeaderProps {
@@ -18,7 +18,7 @@ export const DocsHeader = (props: DocsHeaderProps) => {
<h1 className="!mb-0 text-4xl">{props.title}</h1>
<div className="flex gap-2">
{props.categories.map((category) => (
<Badge>{category}</Badge>
<Badge key={category}>{category}</Badge>
))}
</div>
</div>
+130
View File
@@ -0,0 +1,130 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className,
)}
{...props}
/>
);
}
const inputGroupAddonVariants = cva(
"flex h-auto items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start": "order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end": "order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end": "order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
},
);
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
{...props}
/>
);
}
const inputGroupButtonVariants = cva("flex items-center gap-2 text-sm shadow-none", {
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs": "size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
});
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: "button" | "submit" | "reset";
}) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
);
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function InputGroupInput({ className, ...props }: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className,
)}
{...props}
/>
);
}
function InputGroupTextarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className,
)}
{...props}
/>
);
}
export { InputGroup, InputGroupAddon, InputGroupButton, InputGroupText, InputGroupInput, InputGroupTextarea };
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react";
import { Input as InputPrimitive } from "@base-ui/react/input";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
export { Input };
+22
View File
@@ -0,0 +1,22 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
// Generic primitive: consumers either provide htmlFor or wrap the associated control.
// oxlint-disable-next-line jsx-a11y/label-has-associated-control
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };
+59
View File
@@ -0,0 +1,59 @@
"use client";
import { Progress as ProgressPrimitive } from "@base-ui/react/progress";
import { cn } from "@/lib/utils";
function Progress({ className, children, value, ...props }: ProgressPrimitive.Root.Props) {
return (
<ProgressPrimitive.Root
value={value}
data-slot="progress"
className={cn("flex flex-wrap gap-3", className)}
{...props}
>
{children}
<ProgressTrack>
<ProgressIndicator />
</ProgressTrack>
</ProgressPrimitive.Root>
);
}
function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
return (
<ProgressPrimitive.Track
className={cn("relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted", className)}
data-slot="progress-track"
{...props}
/>
);
}
function ProgressIndicator({ className, ...props }: ProgressPrimitive.Indicator.Props) {
return (
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className={cn("h-full bg-primary transition-all", className)}
{...props}
/>
);
}
function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
return (
<ProgressPrimitive.Label className={cn("text-sm font-medium", className)} data-slot="progress-label" {...props} />
);
}
function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
return (
<ProgressPrimitive.Value
className={cn("ml-auto text-sm text-muted-foreground tabular-nums", className)}
data-slot="progress-value"
{...props}
/>
);
}
export { Progress, ProgressTrack, ProgressIndicator, ProgressLabel, ProgressValue };
+167
View File
@@ -0,0 +1,167 @@
"use client";
import * as React from "react";
import { Select as SelectPrimitive } from "@base-ui/react/select";
import { cn } from "@/lib/utils";
import { IconSelector, IconCheck, IconChevronUp, IconChevronDown } from "@tabler/icons-react";
const Select = SelectPrimitive.Root;
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return <SelectPrimitive.Group data-slot="select-group" className={cn("scroll-my-1 p-1", className)} {...props} />;
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value data-slot="select-value" className={cn("flex flex-1 text-left", className)} {...props} />
);
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon render={<IconSelector className="pointer-events-none size-4 text-muted-foreground" />} />
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<SelectPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger">) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn(
"relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
);
}
function SelectLabel({ className, ...props }: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
);
}
function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />}
>
<IconCheck className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
);
}
function SelectSeparator({ className, ...props }: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<IconChevronUp />
</SelectPrimitive.ScrollUpArrow>
);
}
function SelectScrollDownButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<IconChevronDown />
</SelectPrimitive.ScrollDownArrow>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
+19
View File
@@ -0,0 +1,19 @@
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator";
import { cn } from "@/lib/utils";
function Separator({ className, orientation = "horizontal", ...props }: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:self-stretch",
className,
)}
{...props}
/>
);
}
export { Separator };
+7
View File
@@ -0,0 +1,7 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="skeleton" className={cn("animate-pulse rounded-md bg-muted", className)} {...props} />;
}
export { Skeleton };
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { useColorMode } from "@/hooks/use-color-mode";
import { Toaster as Sonner, type ToasterProps } from "sonner";
import { IconCircleCheck, IconInfoCircle, IconAlertTriangle, IconAlertOctagon, IconLoader } from "@tabler/icons-react";
const Toaster = ({ ...props }: ToasterProps) => {
const { colorMode } = useColorMode();
return (
<Sonner
theme={colorMode}
className="toaster group"
icons={{
success: <IconCircleCheck className="size-4" />,
info: <IconInfoCircle className="size-4" />,
warning: <IconAlertTriangle className="size-4" />,
error: <IconAlertOctagon className="size-4" />,
loading: <IconLoader className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
);
};
export { Toaster };
+30
View File
@@ -0,0 +1,30 @@
import { Switch as SwitchPrimitive } from "@base-ui/react/switch";
import { cn } from "@/lib/utils";
function Switch({
className,
size = "default",
...props
}: SwitchPrimitive.Root.Props & {
size?: "sm" | "default";
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
);
}
export { Switch };
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
}
export { Textarea };
@@ -0,0 +1,87 @@
"use client";
import * as React from "react";
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle";
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group";
import { type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { toggleVariants } from "@/components/ui/toggle";
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number;
orientation?: "horizontal" | "vertical";
}
>({
size: "default",
variant: "default",
spacing: 2,
orientation: "horizontal",
});
function ToggleGroup({
className,
variant,
size,
spacing = 2,
orientation = "horizontal",
children,
...props
}: ToggleGroupPrimitive.Props &
VariantProps<typeof toggleVariants> & {
spacing?: number;
orientation?: "horizontal" | "vertical";
}) {
return (
<ToggleGroupPrimitive
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
data-orientation={orientation}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
className,
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size, spacing, orientation }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive>
);
}
function ToggleGroupItem({
className,
children,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext);
return (
<TogglePrimitive
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className,
)}
{...props}
>
{children}
</TogglePrimitive>
);
}
export { ToggleGroup, ToggleGroupItem };
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const toggleVariants = cva(
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-muted",
},
size: {
default: "h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Toggle({
className,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
return <TogglePrimitive data-slot="toggle" className={cn(toggleVariants({ variant, size, className }))} {...props} />;
}
export { Toggle, toggleVariants };
+52
View File
@@ -0,0 +1,52 @@
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";
import { cn } from "@/lib/utils";
function TooltipProvider({ delay = 0, ...props }: TooltipPrimitive.Provider.Props) {
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delay={delay} {...props} />;
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<TooltipPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+1 -3
View File
@@ -1,9 +1,7 @@
export const AddingWidget = () => {
return (
<p>
You can find how to add the widget on the{" "}
<a href="../../getting-started/after-the-installation#embed-integration-data-using-widgets">Widgets</a>{" "}
documentation page.
Add the widget from the board&apos;s <strong>Add board content</strong> menu in edit mode.
</p>
);
};
@@ -1,6 +1,10 @@
import { useColorMode } from "@docusaurus/theme-common";
"use client";
import { useColorMode } from "@/hooks/use-color-mode";
import { getDocsHref } from "@/lib/docs-path";
import { IntegrationDefinition } from "@site/src/types";
import { IconExternalLink } from "@tabler/icons-react";
import { IconArrowRight } from "@tabler/icons-react";
import Link from "next/link";
import { getIntegrationIconUrl } from "../integrations/header";
interface WidgetIntegrationsProps {
@@ -14,36 +18,38 @@ 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">
<div className="not-prose mt-4 grid w-full gap-2">
<div className="grid 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"
className="flex flex-col gap-4 rounded-xl border bg-fd-card p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex gap-6 items-center">
<div className="flex min-w-0 items-start gap-4 sm:items-center">
<img
width={40}
height={40}
src={getIntegrationIconUrl(item.integration, isDarkTheme)}
alt={`${item.integration.name} icon`}
className="w-10 h-10"
className="size-10 shrink-0 object-contain"
/>
<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 className="min-w-0">
<span className="block text-base font-semibold text-fd-foreground">{item.integration.name}</span>
<span className="mt-1 block text-sm leading-5 text-fd-muted-foreground">
{item.integration.description}
</span>
{item.note && <span className="mt-1 block text-xs text-fd-muted-foreground">{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"
<Link
href={getDocsHref(item.integration.path)}
className="inline-flex shrink-0 items-center justify-center gap-2 rounded-md border px-3 py-2 text-sm font-medium text-fd-foreground hover:bg-fd-muted hover:no-underline"
>
<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>
View guide
<IconArrowRight aria-hidden="true" size={16} stroke={1.5} />
</Link>
</div>
))}
</div>
@@ -0,0 +1,10 @@
export const WidgetInteractionGuide = ({ advancedView = true }: { advancedView?: boolean }) => {
if (!advancedView) return null;
return (
<p>
<strong>Supports advanced view.</strong> See the{" "}
<a href="/docs/advanced/keyboard-shortcuts/">advanced keyboard shortcuts</a>.
</p>
);
};
@@ -0,0 +1,364 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { IconCheck, IconEdit, IconMessageCircle, IconRefresh, IconSend, IconTrash, IconX } from "@tabler/icons-react";
import type { WorkshopComment } from "@site/src/lib/pocketbase";
import { getWorkshopBackend } from "@site/src/lib/pocketbase";
import { githubAvatarUrl, githubProfileUrl } from "@homarr/workshop/schema";
import type { WorkshopUser } from "@homarr/workshop/schema";
import { errorMessage } from "@site/src/lib/utils";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { formatRelativeTime } from "./format";
interface CommentsSectionProps {
submissionId: string;
backend: ReturnType<typeof getWorkshopBackend>;
currentUser: WorkshopUser | null;
onRequireAuth: (action: string) => Promise<string | null>;
}
const avatarFallback = (name: string) => name.trim().slice(0, 1).toUpperCase() || "?";
export const CommentsSection = ({ submissionId, backend, currentUser, onRequireAuth }: CommentsSectionProps) => {
const [rows, setRows] = useState<WorkshopComment[]>([]);
const [loading, setLoading] = useState(true);
const [fetchError, setFetchError] = useState<string | null>(null);
const [reloadKey, setReloadKey] = useState(0);
const [newComment, setNewComment] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [editContent, setEditContent] = useState("");
const [mutationError, setMutationError] = useState<string | null>(null);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const loadComments = useCallback(async () => {
setLoading(true);
setFetchError(null);
try {
setRows(await backend.listComments(submissionId));
} catch (caught) {
const message = errorMessage(caught, "Failed to load comments");
setFetchError(
/collection context/iu.test(message)
? "Comments are unavailable because this Workshop database has not applied the current comments migration."
: message,
);
} finally {
setLoading(false);
}
}, [backend, submissionId]);
useEffect(() => {
void loadComments();
}, [loadComments, reloadKey]);
const handleAdd = async () => {
const trimmed = newComment.trim();
if (!trimmed || !(await onRequireAuth("comment"))) return;
try {
setMutationError(null);
const created = await backend.createComment(submissionId, trimmed);
setRows((previous) => [...previous, created]);
setNewComment("");
} catch (caught) {
setMutationError(errorMessage(caught, "Failed to post comment"));
}
};
const handleUpdate = async (id: string) => {
const trimmed = editContent.trim();
if (!trimmed) return;
try {
setMutationError(null);
const updated = await backend.updateComment(id, trimmed);
setRows((previous) => previous.map((comment) => (comment.id === id ? updated : comment)));
setEditingId(null);
} catch (caught) {
setMutationError(errorMessage(caught, "Failed to update comment"));
}
};
const handleDelete = async (id: string) => {
try {
setMutationError(null);
await backend.deleteComment(id);
setRows((previous) => previous.filter((comment) => comment.id !== id));
setConfirmDeleteId(null);
} catch (caught) {
setMutationError(errorMessage(caught, "Failed to delete comment"));
}
};
return (
<section aria-labelledby="workshop-comments-heading" className="space-y-5">
<div className="flex items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<IconMessageCircle size={18} className="text-muted-foreground" />
<h2 id="workshop-comments-heading" className="text-lg font-semibold">
Discussion
</h2>
{!loading && <Badge variant="secondary">{rows.length}</Badge>}
</div>
<p className="m-0 mt-1 text-sm text-muted-foreground">Questions, fixes, and feedback from the community.</p>
</div>
</div>
{loading && (
<div className="space-y-4" aria-label="Loading comments">
<Skeleton className="h-20 w-3/4 rounded-xl" />
<Skeleton className="ml-auto h-20 w-2/3 rounded-xl bg-primary/10" />
</div>
)}
{!loading && fetchError && (
<Alert variant="destructive">
<IconMessageCircle />
<AlertTitle>Comments could not be loaded</AlertTitle>
<AlertDescription>{fetchError}</AlertDescription>
<Button
variant="ghost"
size="sm"
className="col-start-2 mt-2 w-fit text-destructive hover:bg-destructive/10"
onClick={() => setReloadKey((value) => value + 1)}
>
<IconRefresh size={14} /> Retry comments
</Button>
</Alert>
)}
{!loading && !fetchError && rows.length === 0 && (
<Empty className="bg-muted/40 py-8">
<EmptyHeader>
<EmptyMedia variant="icon">
<IconMessageCircle />
</EmptyMedia>
<EmptyTitle>Start the discussion</EmptyTitle>
<EmptyDescription>Share a question or something you learned using this submission.</EmptyDescription>
</EmptyHeader>
</Empty>
)}
{!loading && !fetchError && rows.length > 0 && (
<div className="space-y-4">
{rows.map((comment) => {
const ownComment = currentUser?.id === comment.author;
const canDelete = ownComment || currentUser?.isAdmin === true;
const isEditing = editingId === comment.id;
const edited = comment.updated !== comment.created;
const authorHandle = ownComment ? (currentUser?.name ?? comment.authorName) : comment.authorName;
const authorName = authorHandle || "Community member";
const avatarUrl = githubAvatarUrl(authorHandle);
const profileUrl = githubProfileUrl(authorHandle);
return (
<article
key={comment.id}
className={ownComment ? "ml-auto w-full max-w-[44rem]" : "w-full max-w-[44rem]"}
>
<div className={ownComment ? "flex flex-row-reverse items-start gap-3" : "flex items-start gap-3"}>
<a
href={profileUrl || undefined}
target={profileUrl ? "_blank" : undefined}
rel="noreferrer"
aria-label={profileUrl ? `Open ${authorName}'s GitHub profile` : undefined}
className="mt-0.5 shrink-0 rounded-full outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
<Avatar className="size-9 ring-1 ring-border">
{avatarUrl && <AvatarImage src={avatarUrl} alt="" />}
<AvatarFallback>{avatarFallback(authorName)}</AvatarFallback>
</Avatar>
</a>
<div className={ownComment ? "flex min-w-0 flex-1 flex-col items-end" : "min-w-0 flex-1"}>
<div
className={
ownComment
? "mb-1.5 flex flex-wrap items-center justify-end gap-x-2 px-1"
: "mb-1.5 flex flex-wrap items-center gap-x-2 px-1"
}
>
<a
href={profileUrl || undefined}
target={profileUrl ? "_blank" : undefined}
rel="noreferrer"
className="truncate text-sm font-semibold text-primary hover:underline"
>
{authorName}
</a>
{ownComment && (
<Badge variant="secondary" className="h-5 px-1.5 text-[0.6875rem]">
You
</Badge>
)}
<span className="text-xs text-muted-foreground">
{formatRelativeTime(comment.created)}
{edited && " · edited"}
</span>
</div>
<div
className={
ownComment
? "w-fit max-w-full rounded-xl rounded-tr-sm bg-primary px-3.5 py-2.5 text-primary-foreground"
: "w-fit max-w-full rounded-xl rounded-tl-sm border border-border/70 bg-muted/60 px-3.5 py-2.5 text-foreground"
}
>
{isEditing ? (
<div className="flex w-full min-w-0 items-end gap-1.5">
<Textarea
className="min-h-20 min-w-0 resize-y bg-background text-foreground"
aria-label="Edit comment"
value={editContent}
rows={3}
maxLength={2000}
onChange={(event) => setEditContent(event.target.value)}
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter")
void handleUpdate(comment.id);
}}
/>
<Button
size="icon-sm"
variant="secondary"
aria-label="Save comment"
onClick={() => void handleUpdate(comment.id)}
>
<IconCheck size={12} />
</Button>
<Button
size="icon-sm"
variant="ghost"
aria-label="Cancel editing"
onClick={() => setEditingId(null)}
>
<IconX size={12} />
</Button>
</div>
) : (
<p className="m-0 break-words whitespace-pre-wrap text-[0.9375rem] leading-6">
{comment.content}
</p>
)}
</div>
{(ownComment || canDelete) && !isEditing && (
<div className={ownComment ? "mt-1 flex justify-end gap-1" : "mt-1 flex gap-1"}>
{ownComment && (
<Button
variant="ghost"
size="xs"
className="text-muted-foreground"
onClick={() => {
setEditingId(comment.id);
setEditContent(comment.content);
}}
>
<IconEdit /> Edit
</Button>
)}
{canDelete && (
<Button
variant="ghost"
size="xs"
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
onClick={() => setConfirmDeleteId(comment.id)}
>
<IconTrash /> Delete
</Button>
)}
</div>
)}
</div>
</div>
</article>
);
})}
</div>
)}
<div className="rounded-xl border border-border bg-muted/30 p-3">
<div className="flex items-start gap-2.5">
<Avatar className="mt-0.5 size-9 bg-background ring-1 ring-border">
{currentUser?.name && <AvatarImage src={githubAvatarUrl(currentUser.name)} alt="" />}
<AvatarFallback className="bg-background">{avatarFallback(currentUser?.name ?? "Guest")}</AvatarFallback>
</Avatar>
<Textarea
className="min-h-16 flex-1 resize-none bg-background"
placeholder={currentUser ? "Add to the discussion…" : "Write a comment, you will be asked to sign in…"}
aria-label="Write a comment"
value={newComment}
maxLength={2000}
onChange={(event) => setNewComment(event.target.value)}
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") void handleAdd();
}}
/>
<Button
size="icon-lg"
className="self-end"
disabled={!newComment.trim()}
onClick={() => void handleAdd()}
aria-label="Post comment"
>
<IconSend size={15} />
</Button>
</div>
<div className="mt-2 flex items-center justify-between gap-4 pl-12 text-xs text-muted-foreground">
<p className="m-0">Press Ctrl or ⌘ + Enter to post.</p>
<span className="tabular-nums">{newComment.length}/2000</span>
</div>
{mutationError && (
<Alert variant="destructive" className="mt-3">
<IconMessageCircle />
<AlertTitle>Comment was not saved</AlertTitle>
<AlertDescription>{mutationError}</AlertDescription>
</Alert>
)}
</div>
<AlertDialog
open={Boolean(confirmDeleteId)}
onOpenChange={(open) => {
if (!open) setConfirmDeleteId(null);
}}
>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Delete this comment?</AlertDialogTitle>
<AlertDialogDescription>This cannot be undone.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
if (confirmDeleteId) void handleDelete(confirmDeleteId);
}}
>
Delete comment
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</section>
);
};
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,41 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ScreenshotGallery } from "./DetailSections";
let host: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(() => root.unmount());
host.remove();
});
async function render(urls: string[]) {
await act(async () => root.render(<ScreenshotGallery urls={urls} title="Gallery" />));
}
describe("ScreenshotGallery", () => {
it("resets an out-of-range selection when screenshots shrink", async () => {
await render(["/one.png", "/two.png", "/three.png"]);
const thirdThumbnail = host.querySelector<HTMLButtonElement>('button[aria-label="Show screenshot 3"]');
if (!thirdThumbnail) throw new Error("Third screenshot thumbnail was not rendered");
await act(async () => thirdThumbnail.click());
expect(host.querySelector<HTMLImageElement>('img[alt="Gallery screenshot 3"]')?.src).toContain("/three.png");
await render(["/one.png"]);
expect(host.querySelector<HTMLImageElement>('img[alt="Gallery screenshot 1"]')?.src).toContain("/one.png");
expect(host.querySelector('img[alt="Gallery screenshot 3"]')).toBeNull();
});
});
@@ -0,0 +1,205 @@
"use client";
import React, { useEffect, useState } from "react";
import { IconChevronLeft, IconChevronRight, IconTrash } from "@tabler/icons-react";
import { DynamicCodeBlock } from "fumadocs-ui/components/dynamic-codeblock";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@site/src/lib/utils";
import { clampScreenshotIndex } from "./workshop-utils";
export const detailLayout = {
shell: "marketplace w-full min-h-[80vh] bg-background text-foreground",
container: "mx-auto max-w-[90rem] px-4 pb-28 pt-8 sm:px-6 sm:pb-20 lg:px-8",
grid: "grid items-start gap-10 xl:grid-cols-[minmax(0,1fr)_22rem]",
sidebar: "space-y-6 xl:sticky xl:top-24",
};
export const DetailSkeleton = () => (
<div className={detailLayout.shell}>
<div className={detailLayout.container} role="status" aria-label="Loading submission" aria-busy="true">
<div aria-hidden="true">
<div className="mb-6 inline-flex items-center px-2.5 py-1.5">
<Skeleton className="h-5 w-36" />
</div>
<div className={detailLayout.grid}>
<div className="min-w-0">
<div className="border-b border-border pb-6">
<div className="flex flex-wrap items-start justify-between gap-5">
<div className="min-w-0 flex-1">
<div className="mb-3 flex items-center gap-2">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-4 w-16" />
</div>
<Skeleton className="h-9 w-2/3 sm:h-10" />
<Skeleton className="mt-3 h-6 w-full" />
<div className="mt-5 flex items-center gap-3">
<Skeleton className="size-10 rounded-full" />
<div className="space-y-1">
<Skeleton className="h-5 w-28" />
<Skeleton className="h-4 w-36" />
</div>
</div>
</div>
<Skeleton className="h-9 w-24 rounded-lg" />
</div>
</div>
<div className="mt-8">
<Skeleton className="h-7 w-20" />
<Skeleton className="mt-1 h-5 w-64 max-w-full" />
<Skeleton className="mt-3 h-[min(58vh,640px)] w-full rounded-xl" />
</div>
<div className="mt-12 border-t border-border pt-9">
<Skeleton className="h-6 w-28" />
<Skeleton className="mt-4 h-20 w-full" />
</div>
</div>
<div className={detailLayout.sidebar}>
<div className="rounded-xl border border-border bg-card p-5">
<Skeleton className="h-7 w-40" />
<div className="mt-3 space-y-2">
<Skeleton className="h-5 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-5/6" />
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Skeleton className="h-11 w-40 sm:h-8" />
<Skeleton className="h-11 w-24 sm:h-8" />
</div>
</div>
<div className="rounded-xl border border-border bg-card p-5">
<Skeleton className="h-6 w-32" />
<div className="mt-5 space-y-5">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-24 w-full" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
export const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: string }) => {
const [idx, setIdx] = useState(0);
const selectedIndex = clampScreenshotIndex(idx, urls.length);
useEffect(() => setIdx((current) => clampScreenshotIndex(current, urls.length)), [urls.length]);
if (urls.length === 0) return null;
return (
<div className="group/gallery w-full">
<div className="relative aspect-video overflow-hidden rounded-lg border border-border bg-muted">
<img
className="h-full w-full object-contain"
src={urls[selectedIndex]}
alt={`${title} screenshot ${selectedIndex + 1}`}
/>
{urls.length > 1 && (
<>
<Button
type="button"
variant="secondary"
size="icon-lg"
className="absolute left-3 top-1/2 -translate-y-1/2 bg-background/90 opacity-85 shadow-sm hover:opacity-100"
onClick={() => setIdx((i) => (i - 1 + urls.length) % urls.length)}
aria-label="Previous screenshot"
>
<IconChevronLeft size={16} />
</Button>
<Button
type="button"
variant="secondary"
size="icon-lg"
className="absolute right-3 top-1/2 -translate-y-1/2 bg-background/90 opacity-85 shadow-sm hover:opacity-100"
onClick={() => setIdx((i) => (i + 1) % urls.length)}
aria-label="Next screenshot"
>
<IconChevronRight size={16} />
</Button>
<span className="absolute right-3 bottom-3 rounded-md bg-background/90 px-2 py-1 text-xs font-medium tabular-nums shadow-sm">
{selectedIndex + 1} / {urls.length}
</span>
</>
)}
</div>
{urls.length > 1 && (
<fieldset className="mt-3 min-w-0">
<legend className="sr-only">Screenshot previews</legend>
<div className="flex gap-2 overflow-x-auto pb-1">
{urls.map((url, i) => {
const selected = i === selectedIndex;
return (
<button
type="button"
key={url}
onClick={() => setIdx(i)}
aria-label={`Show screenshot ${i + 1}`}
aria-pressed={selected}
className={cn(
"aspect-video w-24 shrink-0 overflow-hidden rounded-md border-2 bg-muted transition-[border-color,opacity,box-shadow] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 sm:w-28",
selected
? "border-primary opacity-100 ring-2 ring-primary/20"
: "border-border opacity-65 hover:border-muted-foreground/60 hover:opacity-100",
)}
>
<img className="h-full w-full object-cover" src={url} alt="" loading={selected ? "eager" : "lazy"} />
</button>
);
})}
</div>
</fieldset>
)}
</div>
);
};
export const CodeBlock = ({ content, language }: { content: string; language: string }) => (
<DynamicCodeBlock lang={language} code={content} />
);
export const DeleteConfirmButton = ({ onConfirm, className }: { onConfirm: () => void; className?: string }) => {
return (
<AlertDialog>
<AlertDialogTrigger
render={
<Button variant="outline" size="sm" className={cn("text-destructive hover:bg-destructive/10", className)} />
}
>
<IconTrash size={14} /> Delete
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive">
<IconTrash />
</AlertDialogMedia>
<AlertDialogTitle>Delete submission?</AlertDialogTitle>
<AlertDialogDescription>
This permanently removes the Workshop listing, screenshots, votes, reports, and comments.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={onConfirm}>
Delete submission
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
@@ -0,0 +1,127 @@
"use client";
import { useState } from "react";
import { IconPhoto, IconUpload, IconX } from "@tabler/icons-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export interface ScreenshotEditorItem {
id: string;
src: string;
badge?: string;
}
interface ScreenshotEditorProps {
items: ScreenshotEditorItem[];
onAdd: (files: FileList | File[]) => void;
onRemove: (id: string) => void;
disabled?: boolean;
description?: string;
max?: number;
}
const dropState = {
idle: { Icon: IconUpload, title: "Choose or drop images", iconClass: "bg-muted text-muted-foreground" },
active: { Icon: IconPhoto, title: "Drop images here", iconClass: "bg-primary text-primary-foreground" },
} as const;
export const ScreenshotEditor = ({
items,
onAdd,
onRemove,
disabled = false,
description = "Add screenshots to help others preview your submission.",
max = 5,
}: ScreenshotEditorProps) => {
const [dragOver, setDragOver] = useState(false);
const full = items.length >= max;
const { Icon, title, iconClass } = dropState[dragOver ? "active" : "idle"];
return (
<section className="flex flex-col gap-3" aria-labelledby="workshop-screenshots-heading">
<div className="flex items-start justify-between gap-4">
<div>
<h3 id="workshop-screenshots-heading" className="text-sm font-medium">
Screenshots
</h3>
<p className="mt-0.5 text-xs text-muted-foreground">{description}</p>
</div>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{items.length}/{max}
</span>
</div>
{items.length > 0 && (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
{items.map((item, index) => (
<div
key={item.id}
className="group relative aspect-[16/10] overflow-hidden rounded-lg border border-border bg-muted"
>
<img src={item.src} alt={`Screenshot ${index + 1}`} className="size-full object-cover" />
{item.badge && (
<span className="absolute bottom-1.5 left-1.5 rounded-md bg-background/90 px-1.5 py-0.5 text-[10px] font-medium text-foreground shadow-sm backdrop-blur-sm">
{item.badge}
</span>
)}
<Button
type="button"
variant="destructive"
size="icon-sm"
disabled={disabled}
onClick={() => onRemove(item.id)}
className="absolute top-1.5 right-1.5 opacity-90 shadow-sm transition-opacity group-hover:opacity-100"
aria-label={`Remove screenshot ${index + 1}`}
>
<IconX size={14} />
</Button>
</div>
))}
</div>
)}
{!full && (
// oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<label
onDragOver={(event) => {
event.preventDefault();
event.stopPropagation();
if (!disabled) setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(event) => {
event.preventDefault();
event.stopPropagation();
setDragOver(false);
if (!disabled) onAdd(event.dataTransfer.files);
}}
className={cn(
"flex cursor-pointer items-center gap-3 rounded-lg border border-dashed border-border px-4 py-3 transition-colors hover:border-primary/50 hover:bg-primary/5",
dragOver && "border-primary bg-primary/10",
disabled && "pointer-events-none opacity-50",
)}
>
<span className={cn("flex size-9 shrink-0 items-center justify-center rounded-lg", iconClass)}>
<Icon size={18} />
</span>
<span className="min-w-0">
<span className="block text-sm font-medium">{title}</span>
<span className="block text-xs text-muted-foreground">PNG, JPG, or WebP · up to 5 MiB each</span>
</span>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
multiple
disabled={disabled}
className="hidden"
onChange={(event) => {
if (event.target.files) onAdd(event.target.files);
event.currentTarget.value = "";
}}
/>
</label>
)}
</section>
);
};
@@ -0,0 +1,487 @@
"use client";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
IconAlertCircle,
IconBraces,
IconCheck,
IconChevronLeft,
IconChevronRight,
IconFileUpload,
IconLoader2,
IconPalette,
} from "@tabler/icons-react";
import type { SubmissionType } from "@site/src/lib/workshop-schema";
import { validateSubmissionContent } from "@site/src/lib/workshop-schema";
import { CustomWidgetCodeInput } from "@/components/custom-widget-code";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Progress, ProgressLabel, ProgressValue } from "@/components/ui/progress";
import { Textarea } from "@/components/ui/textarea";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { track } from "@/lib/analytics";
import { cn, errorMessage } from "@/lib/utils";
import type { SubmitInput } from "./useWorkshop";
import { ScreenshotEditor } from "./ScreenshotEditor";
interface Props {
onClose: () => void;
onSubmit: (input: SubmitInput) => Promise<void>;
}
const steps = ["Type", "Details", "Media"] as const;
const backLabels = ["Cancel", "Back"];
const submitLabels = ["Publish submission", "Publishing…"];
const dropOverlayClass = ["pointer-events-none opacity-0", "opacity-100"];
const placeholders: Record<SubmissionType, string> = {
customWidget: '{\n "$schema": "homarr-custom-widget-v2",\n "name": "My widget",\n ...\n}',
customCss: ".grid-stack-item-content {\n border-radius: 16px;\n}",
};
const contentLabels: Record<SubmissionType, string> = {
customWidget: "Widget JSON (homarr-custom-widget-v2)",
customCss: "Custom CSS",
};
const parseJsonObject = (json: string): Record<string, unknown> | null => {
try {
const parsed = JSON.parse(json);
if (typeof parsed === "object" && parsed !== null) return parsed;
} catch {
/* invalid json */
}
return null;
};
export const SubmitForm = ({ onClose, onSubmit }: Props) => {
const [step, setStep] = useState(0);
const [type, setType] = useState<SubmissionType | null>(null);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [changelog, setChangelog] = useState("Initial publication");
const [content, setContent] = useState("");
const [screenshots, setScreenshots] = useState<File[]>([]);
const [previews, setPreviews] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
const [jsonDropHint, setJsonDropHint] = useState(false);
const previewUrlsRef = useRef<Set<string>>(new Set());
useEffect(() => {
track("Workshop Submit Started");
}, []);
const canAdvance = [type !== null, title.trim().length >= 3 && content.trim().length > 0, true];
const goTo = useCallback((next: number) => setStep(next), []);
const setContentAndAutofill = useCallback((json: string, currentTitle: string, currentDesc: string) => {
setContent(json);
const meta = parseJsonObject(json);
if (!meta) return;
if (typeof meta.name === "string" && !currentTitle) setTitle(meta.name);
if (typeof meta.description === "string" && !currentDesc) setDescription(String(meta.description));
}, []);
const handleJsonFileDrop = useCallback(
(files: FileList | File[]) => {
const jsonFile = Array.from(files).find((f) => f.name.endsWith(".json") || f.type === "application/json");
if (!jsonFile) return false;
const reader = new FileReader();
reader.addEventListener("load", (e) => {
const text = e.target?.result as string;
setType("customWidget");
setContentAndAutofill(text, title, description);
if (step === 0) goTo(1);
});
reader.addEventListener("error", () => setError("Could not read the file"));
reader.readAsText(jsonFile);
return true;
},
[step, title, description, setContentAndAutofill, goTo],
);
const addImageFiles = useCallback(
(files: FileList | File[]) => {
const incoming = Array.from(files)
.filter((f) => f.type.startsWith("image/"))
.slice(0, 5 - screenshots.length);
if (incoming.length === 0) return;
setScreenshots((prev) => [...prev, ...incoming].slice(0, 5));
const urls = incoming.map((f) => {
const url = URL.createObjectURL(f);
previewUrlsRef.current.add(url);
return url;
});
setPreviews((prev) => [...prev, ...urls].slice(0, 5));
},
[screenshots.length],
);
const removeScreenshot = (idx: number) => {
const url = previews[idx];
if (url) {
previewUrlsRef.current.delete(url);
URL.revokeObjectURL(url);
}
setScreenshots((prev) => prev.filter((_, i) => i !== idx));
setPreviews((prev) => prev.filter((_, i) => i !== idx));
};
useEffect(
() => () => {
previewUrlsRef.current.forEach(URL.revokeObjectURL);
previewUrlsRef.current.clear();
},
[],
);
const handleGlobalDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setJsonDropHint(false);
if (!handleJsonFileDrop(e.dataTransfer.files) && step === 2) addImageFiles(e.dataTransfer.files);
},
[handleJsonFileDrop, step, addImageFiles],
);
const handleSubmit = async () => {
if (!type) return;
const validation = validateSubmissionContent(type, content);
if (!validation.success) {
setError(validation.error);
goTo(1);
return;
}
setError(null);
setPending(true);
try {
await onSubmit({ type, title, description, changelog, content, screenshots });
} catch (caught) {
setError(errorMessage(caught, "Submission failed"));
} finally {
setPending(false);
}
};
const onContentChange = (value: string) => {
if (type === "customWidget") setContentAndAutofill(value, title, description);
else setContent(value);
};
return (
<Dialog
open
onOpenChange={(open) => {
if (!open && !pending) onClose();
}}
>
{/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
<DialogContent
className="max-h-[92vh] overflow-y-auto sm:max-w-5xl"
showCloseButton={!pending}
onDragOver={(e: React.DragEvent) => {
e.preventDefault();
setJsonDropHint(true);
}}
onDragLeave={() => setJsonDropHint(false)}
onDrop={handleGlobalDrop}
>
<div
aria-hidden={!jsonDropHint}
className={cn(
"absolute inset-0 z-50 flex flex-col items-center justify-center gap-3 rounded-xl bg-primary/10 backdrop-blur-sm transition-opacity duration-200",
dropOverlayClass[Number(jsonDropHint)],
)}
>
<div className="flex size-14 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
<IconFileUpload size={28} />
</div>
<p className="text-sm font-semibold">Drop JSON file to import widget</p>
<p className="text-xs text-muted-foreground">Title and description will be auto-filled</p>
</div>
<DialogHeader>
<DialogTitle>Share with the community</DialogTitle>
<DialogDescription>Submit a custom CSS theme or widget to the Workshop.</DialogDescription>
</DialogHeader>
<Progress value={((step + 1) / steps.length) * 100}>
<ProgressLabel>{steps[step]}</ProgressLabel>
<ProgressValue>{() => `Step ${step + 1} of ${steps.length}`}</ProgressValue>
</Progress>
{error && (
<Alert variant="destructive">
<IconAlertCircle />
<AlertTitle>Submission needs attention</AlertTitle>
<AlertDescription className="whitespace-pre-wrap">{error}</AlertDescription>
</Alert>
)}
<div className="relative min-h-[280px]">
<div key={step}>
{step === 0 && <StepType value={type} onChange={setType} onJsonDrop={handleJsonFileDrop} />}
{step === 1 && type && (
<StepDetails
type={type}
title={title}
setTitle={setTitle}
description={description}
setDescription={setDescription}
changelog={changelog}
setChangelog={setChangelog}
content={content}
onContentChange={onContentChange}
/>
)}
{step === 2 && (
<ScreenshotEditor
items={previews.map((src) => ({ id: src, src }))}
onAdd={addImageFiles}
onRemove={(id) => removeScreenshot(previews.indexOf(id))}
/>
)}
</div>
</div>
<DialogFooter className="sm:justify-between">
<Button
variant="ghost"
className="w-full sm:w-auto"
onClick={() => {
if (step === 0) onClose();
else goTo(step - 1);
}}
disabled={pending}
>
<IconChevronLeft size={14} /> {backLabels[Math.min(step, 1)]}
</Button>
<div className="flex w-full items-center gap-2 sm:w-auto">
{step < 2 && (
<Button className="w-full sm:w-auto" onClick={() => goTo(step + 1)} disabled={!canAdvance[step]}>
Next <IconChevronRight size={14} />
</Button>
)}
{step >= 2 && (
<Button
className="w-full sm:w-auto"
onClick={() => void handleSubmit()}
disabled={pending || !canAdvance[1]}
>
{pending && <IconLoader2 size={14} className="animate-spin" />}
{submitLabels[Number(pending)]}
</Button>
)}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
const typeCards: { type: SubmissionType; icon: typeof IconBraces; label: string; desc: string }[] = [
{
type: "customWidget",
icon: IconBraces,
label: "Custom Widget",
desc: "A JSON-based widget using the homarr-custom-widget-v2 schema",
},
{
type: "customCss",
icon: IconPalette,
label: "Custom CSS",
desc: "A CSS theme or style override for Homarr dashboards",
},
];
const StepType = ({
value,
onChange,
onJsonDrop,
}: {
value: SubmissionType | null;
onChange: (t: SubmissionType) => void;
onJsonDrop: (files: FileList | File[]) => boolean;
}) => {
const [dropActive, setDropActive] = useState(false);
return (
<div className="flex flex-col gap-3">
<ToggleGroup
value={value ? [value] : []}
onValueChange={(values) => {
const selected = values[0] as SubmissionType | undefined;
if (selected) onChange(selected);
}}
spacing={12}
className="grid w-full grid-cols-1 gap-3 sm:grid-cols-2"
aria-label="Submission type"
>
{typeCards.map(({ type, icon: Icon, label, desc }) => (
<ToggleGroupItem
key={type}
value={type}
aria-label={`${label}: ${desc}`}
className={cn(
"group relative h-auto min-h-36 w-full flex-col items-start justify-start gap-3 rounded-xl border p-4 text-left whitespace-normal transition-colors hover:border-primary/50 hover:bg-primary/5 sm:min-h-40",
"data-pressed:border-primary data-pressed:bg-primary/10 data-pressed:text-foreground data-pressed:ring-1 data-pressed:ring-primary/20",
)}
>
<div
className={cn(
"flex size-10 items-center justify-center rounded-lg transition-colors",
value === type
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary",
)}
>
<Icon size={20} />
</div>
<div>
<p className="font-heading text-sm font-semibold">{label}</p>
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">{desc}</p>
</div>
{value === type && (
<div className="absolute top-3 right-3 flex size-6 items-center justify-center rounded-full bg-primary text-primary-foreground">
<IconCheck size={12} />
</div>
)}
</ToggleGroupItem>
))}
</ToggleGroup>
{/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
<label
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
setDropActive(true);
}}
onDragLeave={() => setDropActive(false)}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
setDropActive(false);
onJsonDrop(e.dataTransfer.files);
}}
className={cn(
"flex cursor-pointer items-center gap-3 rounded-xl border border-dashed p-3 transition-all hover:border-primary/40 hover:bg-primary/5",
dropActive && "border-primary bg-primary/10",
)}
>
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<IconFileUpload size={16} />
</div>
<div className="min-w-0">
<p className="text-xs font-medium">Or drop a .json file here</p>
<p className="text-xs text-muted-foreground">Auto-selects Widget type and fills title from JSON</p>
</div>
<input
type="file"
aria-label="Choose a widget JSON file"
accept=".json,application/json"
className="hidden"
onChange={(e) => {
if (e.target.files) onJsonDrop(e.target.files);
}}
/>
</label>
</div>
);
};
const StepDetails = ({
type,
title,
setTitle,
description,
setDescription,
changelog,
setChangelog,
content,
onContentChange,
}: {
type: SubmissionType;
title: string;
setTitle: (v: string) => void;
description: string;
setDescription: (v: string) => void;
changelog: string;
setChangelog: (v: string) => void;
content: string;
onContentChange: (v: string) => void;
}) => (
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.45fr)_minmax(18rem,0.55fr)]">
<div className="flex flex-col gap-1.5">
<CustomWidgetCodeInput
id="submit-content"
label={contentLabels[type]}
language={type === "customWidget" ? "json" : "css"}
value={content}
onChange={onContentChange}
placeholder={placeholders[type]}
required
height="min(44vh, 440px)"
maxLength={type === "customCss" ? 16_384 : undefined}
description={
type === "customWidget"
? "Paste exported widget JSON. The title and description are filled from its metadata."
: "Paste the stylesheet that users will import into their board settings."
}
/>
</div>
<FieldGroup className="gap-4">
<Field>
<FieldLabel htmlFor="submit-title">
Title <span aria-hidden="true">*</span>
</FieldLabel>
<Input
id="submit-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
minLength={3}
maxLength={100}
placeholder="My awesome theme"
/>
</Field>
<Field>
<FieldLabel htmlFor="submit-description">Description</FieldLabel>
<Textarea
id="submit-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
maxLength={2000}
rows={5}
placeholder="A brief description of what this does"
/>
</Field>
<Field>
<FieldLabel htmlFor="submit-changelog">Changelog</FieldLabel>
<Textarea
id="submit-changelog"
value={changelog}
onChange={(e) => setChangelog(e.target.value)}
maxLength={2000}
rows={4}
placeholder="What is included in this revision?"
/>
</Field>
</FieldGroup>
</div>
);
@@ -0,0 +1,88 @@
"use client";
import { IconBrandGithub, IconChevronDown, IconExternalLink, IconLogout } from "@tabler/icons-react";
import { githubAvatarUrl, githubProfileUrl } from "@homarr/workshop/schema";
import type { WorkshopUser } from "@homarr/workshop/schema";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const avatarFallback = (name: string) => name.trim().slice(0, 1).toUpperCase() || "?";
interface WorkshopAccountMenuProps {
user: WorkshopUser;
onSignOut: () => void;
}
export const WorkshopAccountMenu = ({ user, onSignOut }: WorkshopAccountMenuProps) => {
const profileUrl = githubProfileUrl(user.name);
const avatarUrl = githubAvatarUrl(user.name);
const accountLabel = user.name ? `@${user.name}` : "Account";
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="sm" className="h-10 gap-2 px-2 sm:h-8" aria-label="Workshop account menu" />
}
>
<Avatar className="size-6">
{avatarUrl && <AvatarImage src={avatarUrl} alt="" />}
<AvatarFallback className="text-[10px]">{avatarFallback(user.name)}</AvatarFallback>
</Avatar>
<span className="max-w-36 truncate">{accountLabel}</span>
<IconChevronDown size={13} aria-hidden="true" className="text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64" aria-label="Workshop account">
<DropdownMenuGroup>
<DropdownMenuLabel className="flex items-center gap-3 px-2 py-2 font-normal text-foreground">
<Avatar className="size-9">
{avatarUrl && <AvatarImage src={avatarUrl} alt="" />}
<AvatarFallback>{avatarFallback(user.name)}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{accountLabel}</p>
<p className="text-xs text-muted-foreground">Workshop account</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{profileUrl && (
<DropdownMenuItem
render={
<a
href={profileUrl}
target="_blank"
rel="noreferrer"
className="no-underline"
aria-label="View GitHub profile"
/>
}
>
<IconBrandGithub aria-hidden="true" />
View GitHub profile
<IconExternalLink aria-hidden="true" className="ml-auto text-muted-foreground" />
</DropdownMenuItem>
)}
<DropdownMenuItem variant="destructive" onClick={onSignOut}>
<IconLogout aria-hidden="true" />
Sign out
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
};
@@ -0,0 +1,388 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
IconBrandGithub,
IconExternalLink,
IconFlag,
IconLoader2,
IconRefresh,
IconShield,
IconTrash,
} from "@tabler/icons-react";
import { WorkshopBackend } from "@homarr/workshop/backend";
import { githubAvatarUrl, githubProfileUrl } from "@homarr/workshop/schema";
import type { WorkshopReport, WorkshopSubmissionSummary, WorkshopUser } from "@homarr/workshop/schema";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
import { Skeleton } from "@/components/ui/skeleton";
const avatarFallback = (name: string) => name.trim().slice(0, 1).toUpperCase() || "?";
export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) {
const client = useMemo(() => {
const url = workshopUrl || (typeof window === "undefined" ? "" : window.location.origin);
return url ? new WorkshopBackend(url) : new WorkshopBackend();
}, [workshopUrl]);
const [user, setUser] = useState<WorkshopUser | null>(null);
const [reports, setReports] = useState<WorkshopReport[]>([]);
const [submissions, setSubmissions] = useState<WorkshopSubmissionSummary[]>([]);
const [busyId, setBusyId] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<WorkshopSubmissionSummary | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const load = useCallback(async () => {
setLoading(true);
try {
const [nextReports, nextSubmissions] = await Promise.all([
client.listReports(),
client.listAll({ sort: "newest" }),
]);
setReports(nextReports);
setSubmissions(nextSubmissions);
setError("");
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Unable to load Workshop moderation");
} finally {
setLoading(false);
}
}, [client]);
useEffect(() => {
const unsubscribe = client.subscribeToAuth(setUser);
void client
.refreshAuth()
.then(async (nextUser) => {
setUser(nextUser);
if (nextUser?.isAdmin) await load();
else setLoading(false);
})
.catch((cause) => {
setError(cause instanceof Error ? cause.message : "Unable to check Workshop moderator access");
setLoading(false);
});
return unsubscribe;
}, [client, load]);
const signIn = async () => {
try {
const nextUser = await client.signInWithGitHub();
setUser(nextUser);
if (nextUser?.isAdmin) await load();
} catch (cause) {
setError(cause instanceof Error ? cause.message : "GitHub sign-in failed");
}
};
const dismiss = async (reportId: string) => {
setBusyId(reportId);
try {
await client.dismissReport(reportId);
await load();
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Unable to dismiss report");
} finally {
setBusyId(null);
}
};
const remove = async () => {
if (!pendingDelete) return;
setBusyId(pendingDelete.id);
try {
await client.delete(pendingDelete.id);
setPendingDelete(null);
await load();
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Unable to delete submission");
} finally {
setBusyId(null);
}
};
const grouped = submissions
.map((submission) => ({
submission,
reports: reports.filter((report) => report.submission === submission.id),
}))
.toSorted(
(left, right) =>
right.reports.length - left.reports.length ||
Date.parse(right.submission.updated) - Date.parse(left.submission.updated),
);
return (
<main className="marketplace mx-auto max-w-6xl px-4 pb-20 text-foreground sm:px-6">
<header className="flex flex-col gap-5 border-b border-border py-8 sm:flex-row sm:items-end sm:justify-between">
<div>
<div className="mb-2 flex items-center gap-2 text-primary">
<IconShield size={18} />
<span className="text-sm font-medium">Restricted area</span>
</div>
<h1 className="text-3xl font-semibold tracking-tight">Workshop moderation</h1>
<p className="mt-2 max-w-2xl text-sm text-muted-foreground">
Review reports and remove unsafe or outdated submissions. PocketBase rules enforce every action.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" nativeButton={false} render={<a href="/workshop" aria-label="Back to Workshop" />}>
Back to Workshop
</Button>
{user?.isAdmin && (
<Button variant="outline" disabled={loading} onClick={() => void load()}>
<IconRefresh size={15} className={loading ? "animate-spin" : undefined} />
{loading ? "Refreshing" : "Refresh data"}
</Button>
)}
</div>
</header>
<div className="py-6">
{loading && !user && (
<div className="space-y-3 rounded-xl border border-border p-6" aria-label="Checking moderator access">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-full max-w-md" />
<Skeleton className="h-8 w-40" />
</div>
)}
{!user && !loading && (
<Empty className="min-h-72 border border-border">
<EmptyHeader>
<EmptyMedia variant="icon">
<IconShield />
</EmptyMedia>
<EmptyTitle>Sign in to moderate the Workshop</EmptyTitle>
<EmptyDescription>
Moderator access is checked against your GitHub-linked Workshop account.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button onClick={() => void signIn()}>
<IconBrandGithub /> Sign in with GitHub
</Button>
</EmptyContent>
</Empty>
)}
{user && !user.isAdmin && (
<Alert variant="destructive" className="mb-5">
<IconShield />
<AlertTitle>Workshop moderator access required</AlertTitle>
<AlertDescription>This GitHub account cannot access Workshop moderation.</AlertDescription>
</Alert>
)}
{error && (
<Alert variant="destructive" className="mb-5">
<IconFlag />
<AlertTitle>Workshop moderation failed</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{user?.isAdmin && (
<section aria-busy={loading}>
{reports.length > 0 && (
<Alert variant="destructive" className="mb-5">
<IconFlag />
<AlertTitle>
{reports.length} open {reports.length === 1 ? "report needs" : "reports need"} review
</AlertTitle>
<AlertDescription>
Reported submissions are highlighted and shown first. Dismiss reports that need no action, or delete
the submission when it should no longer be available.
</AlertDescription>
</Alert>
)}
<div className="mb-5 flex flex-wrap items-center gap-2 text-sm">
<Badge variant={reports.length > 0 ? "destructive" : "secondary"}>
{reports.length} open {reports.length === 1 ? "report" : "reports"}
</Badge>
<span className="text-muted-foreground">
{submissions.length} published {submissions.length === 1 ? "submission" : "submissions"}
</span>
</div>
{loading && submissions.length === 0 && (
<div className="space-y-4" aria-label="Loading Workshop moderation">
{Array.from({ length: 3 }, (_, index) => (
<Card key={index}>
<CardHeader className="sm:grid-cols-[1fr_auto]">
<div className="flex items-center gap-3">
<Skeleton className="size-10 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-4 w-52" />
<Skeleton className="h-3 w-28" />
</div>
</div>
<Skeleton className="h-5 w-20 rounded-full" />
</CardHeader>
<CardContent>
<Skeleton className="h-14 w-full" />
</CardContent>
</Card>
))}
</div>
)}
<div className="space-y-4" aria-live="polite">
{grouped.map(({ submission, reports: submissionReports }) => (
<Card
key={submission.id}
className={
submissionReports.length > 0
? "overflow-visible bg-destructive/[0.025] ring-destructive/50"
: "overflow-visible"
}
>
<CardHeader className="border-b border-border pb-4 sm:grid-cols-[1fr_auto]">
<div className="flex min-w-0 items-center gap-3">
<Avatar className="size-10">
{submission.authorName && <AvatarImage src={githubAvatarUrl(submission.authorName)} alt="" />}
<AvatarFallback>{avatarFallback(submission.authorName)}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<CardTitle className="truncate text-base">{submission.title}</CardTitle>
<a
href={githubProfileUrl(submission.authorName) || undefined}
target={submission.authorName ? "_blank" : undefined}
rel="noreferrer"
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
>
{submission.authorName || "Community member"}
</a>
</div>
</div>
<Badge variant={submissionReports.length > 0 ? "destructive" : "secondary"}>
{submissionReports.length} {submissionReports.length === 1 ? "report" : "reports"}
</Badge>
</CardHeader>
<CardContent className="space-y-5 pt-1">
<div>
<p className="text-sm leading-relaxed text-muted-foreground">
{submission.description || "No description was provided."}
</p>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>{submission.upvotes} upvotes</span>
<span>{submission.downvotes} downvotes</span>
<span>Score {submission.score}</span>
<span>Updated {new Date(submission.updated).toLocaleDateString()}</span>
</div>
</div>
{submissionReports.length > 0 && (
<div className="space-y-3">
{submissionReports.map((report) => (
<Alert key={report.id} variant="destructive">
<IconFlag />
<div className="col-start-2 min-w-0">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<AlertTitle className="col-auto capitalize">{report.category} report</AlertTitle>
<p className="m-0 mt-1 text-xs text-destructive/75">
Reported by {report.reporterName || "Community member"} ·{" "}
{new Date(report.created).toLocaleString()}
</p>
</div>
<Button
variant="outline"
size="sm"
className="border-destructive/30 bg-background/70 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={busyId === report.id}
onClick={() => void dismiss(report.id)}
>
{busyId === report.id && <IconLoader2 size={14} className="animate-spin" />}
{busyId === report.id ? "Dismissing" : "Dismiss report"}
</Button>
</div>
<AlertDescription className="col-auto mt-3 whitespace-pre-wrap">
{report.explanation}
</AlertDescription>
</div>
</Alert>
))}
</div>
)}
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-4">
<Button
variant="outline"
size="sm"
nativeButton={false}
render={<a href={`/workshop/${submission.id}`} aria-label={`Inspect ${submission.title}`} />}
>
<IconExternalLink size={14} /> Inspect submission
</Button>
<Button
variant={submissionReports.length > 0 ? "destructive" : "ghost"}
size="sm"
className={submissionReports.length > 0 ? undefined : "text-destructive hover:text-destructive"}
onClick={() => setPendingDelete(submission)}
>
<IconTrash size={14} /> Delete submission
</Button>
</div>
</CardContent>
</Card>
))}
</div>
{!loading && grouped.length === 0 && (
<Empty className="border border-border py-16">
<EmptyHeader>
<EmptyMedia variant="icon">
<IconShield />
</EmptyMedia>
<EmptyTitle>No Workshop submissions</EmptyTitle>
<EmptyDescription>Published submissions will appear here.</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</section>
)}
</div>
<AlertDialog open={pendingDelete !== null} onOpenChange={(open) => !open && !busyId && setPendingDelete(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive">
<IconTrash />
</AlertDialogMedia>
<AlertDialogTitle>Delete {pendingDelete?.title}?</AlertDialogTitle>
<AlertDialogDescription>
This removes the Workshop listing and its reports, votes, screenshots, and comments. Installed local
copies remain. The submission author will be notified by email.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={busyId !== null}>Cancel</AlertDialogCancel>
<AlertDialogAction variant="destructive" disabled={busyId !== null} onClick={() => void remove()}>
{busyId && <IconLoader2 size={14} className="animate-spin" />}
{busyId ? "Deleting" : "Delete submission"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</main>
);
}
Loaded 100 of 3286 files, more files were not shown because too many files have changed in this diff. Show more