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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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;
|
||||
@@ -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)),
|
||||
};
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
+10
-2
@@ -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>
|
||||
);
|
||||
};
|
||||
Binary file not shown.
Binary file not shown.
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,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'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
Reference in new issue
Block a user