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,33 @@
|
||||
import { pageMetadata } from "@/lib/metadata";
|
||||
|
||||
import { CodeContributorList } from "@/components/pages/about/code-contributors/code-contributor-list";
|
||||
import { TranslationContributorList } from "@/components/pages/about/translations-contributors/translation-constributor-list";
|
||||
|
||||
export const metadata = pageMetadata({
|
||||
path: "/about-us",
|
||||
title: "About Homarr",
|
||||
description: "The volunteers and contributors behind the Homarr open-source project.",
|
||||
});
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-5xl px-5 py-16 sm:px-8">
|
||||
<header className="max-w-3xl border-b pb-10">
|
||||
<h1 className="text-4xl font-semibold tracking-tight sm:text-5xl">About us</h1>
|
||||
<p className="mt-4 text-lg leading-8 text-fd-muted-foreground">
|
||||
Homarr is a community driven open source project that is being maintained by volunteers. Thanks to these
|
||||
people, Homarr has been a growing project since 2021. Our team is working completely remote from many
|
||||
different countries on Homarr in their leisure time for no compensation.
|
||||
</p>
|
||||
</header>
|
||||
<section className="pt-10">
|
||||
<h2 className="mb-5 text-xl font-semibold">Code contributions</h2>
|
||||
<CodeContributorList />
|
||||
</section>
|
||||
<section className="pt-12">
|
||||
<h2 className="mb-5 text-xl font-semibold">Translation contributions</h2>
|
||||
<TranslationContributorList />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { IconBrandGithub } from "@tabler/icons-react";
|
||||
import { InlineTOC } from "fumadocs-ui/components/inline-toc";
|
||||
import { DocsBody, MarkdownCopyButton, ViewOptionsPopover } from "fumadocs-ui/layouts/docs/page";
|
||||
|
||||
import homarrV2SocialImage from "@site/blog/2026/09-03-homarr-2.0/img/homarr-v2-recap.webp";
|
||||
|
||||
import { getBlogAuthor } from "@/lib/blog-authors";
|
||||
import { pageMetadata } from "@/lib/metadata";
|
||||
import { BlogToc } from "@/components/blog/blog-toc";
|
||||
import { getMDXComponents } from "@/components/mdx";
|
||||
import {
|
||||
getPostBySegments,
|
||||
getPostExcerpt,
|
||||
getPostMarkdownUrl,
|
||||
getPostSegments,
|
||||
getPostUrl,
|
||||
posts,
|
||||
} from "@/lib/source";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ slug: string[] }>;
|
||||
}
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("en", { dateStyle: "long", timeZone: "UTC" });
|
||||
|
||||
function BlogArchive({ currentPath }: { currentPath: string }) {
|
||||
const recentPosts = posts.entries.toSorted((left, right) => right.date.localeCompare(left.date)).slice(0, 5);
|
||||
const years = [...new Set(recentPosts.map((post) => post.date.slice(0, 4)))];
|
||||
|
||||
return (
|
||||
<aside className="sticky top-24 hidden max-h-[calc(100dvh-8rem)] self-start overflow-y-auto 2xl:block">
|
||||
<h2 className="text-base font-semibold">Recent posts</h2>
|
||||
<div className="mt-5 space-y-6">
|
||||
{years.map((year) => (
|
||||
<section key={year} aria-labelledby={`blog-year-${year}`}>
|
||||
<h3 id={`blog-year-${year}`} className="text-sm font-semibold text-fd-muted-foreground">
|
||||
{year}
|
||||
</h3>
|
||||
<ul className="mt-2 space-y-2.5 text-sm leading-5">
|
||||
{recentPosts
|
||||
.filter((post) => post.date.startsWith(year))
|
||||
.map((post) => {
|
||||
const url = getPostUrl(post);
|
||||
return (
|
||||
<li key={post.info.path}>
|
||||
<Link
|
||||
href={url}
|
||||
aria-current={url === currentPath ? "page" : undefined}
|
||||
className={
|
||||
url === currentPath
|
||||
? "font-medium text-fd-primary"
|
||||
: "text-fd-muted-foreground transition-colors hover:text-fd-foreground"
|
||||
}
|
||||
>
|
||||
{post.title}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function BlogPostPage({ params }: PageProps) {
|
||||
const { slug } = await params;
|
||||
const post = getPostBySegments(slug);
|
||||
if (!post) notFound();
|
||||
|
||||
const MDX = post.body;
|
||||
const markdownUrl = getPostMarkdownUrl(post);
|
||||
const hasBodyTitle = post.toc[0]?.depth === 1;
|
||||
const bodyTitleId = hasBodyTitle ? decodeURIComponent(post.toc[0].url.slice(1)) : undefined;
|
||||
const toc = hasBodyTitle ? post.toc.filter((item) => item.depth !== 1) : post.toc;
|
||||
const postUrl = getPostUrl(post);
|
||||
const rawPost = await post.getText("raw");
|
||||
const readingMinutes = Math.max(1, Math.ceil(rawPost.split(/\s+/).length / 220));
|
||||
|
||||
return (
|
||||
<main className="mx-auto grid w-full max-w-[100rem] gap-x-8 px-5 py-10 sm:px-8 lg:py-14 xl:grid-cols-[minmax(0,60rem)_15rem] xl:justify-center 2xl:grid-cols-[15rem_minmax(0,60rem)_15rem]">
|
||||
<BlogArchive currentPath={postUrl} />
|
||||
<article className="min-w-0">
|
||||
<header className="pb-8">
|
||||
<p className="text-sm text-fd-muted-foreground">
|
||||
<time dateTime={post.date}>{dateFormatter.format(new Date(post.date))}</time>
|
||||
<span aria-hidden="true"> · </span>
|
||||
{readingMinutes} min read
|
||||
</p>
|
||||
<h1
|
||||
id={bodyTitleId}
|
||||
className="mt-3 text-[clamp(2.25rem,4vw,3.25rem)] leading-[1.05] font-[750] tracking-[-0.035em] text-balance"
|
||||
>
|
||||
{post.title}
|
||||
</h1>
|
||||
<div className="mt-6 grid gap-5 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
|
||||
<ul className="flex min-w-0 flex-wrap gap-x-5 gap-y-3" aria-label="Authors">
|
||||
{post.authors.map((author) => {
|
||||
const profile = getBlogAuthor(author);
|
||||
|
||||
return (
|
||||
<li key={author}>
|
||||
<a
|
||||
href={`https://github.com/${profile.github}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="group flex items-center gap-3"
|
||||
>
|
||||
<img
|
||||
src={profile.image}
|
||||
alt=""
|
||||
width={48}
|
||||
height={48}
|
||||
className="size-12 rounded-full bg-fd-muted object-cover"
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-sm font-semibold group-hover:text-fd-primary">{profile.name}</span>
|
||||
<span className="mt-0.5 flex items-center gap-1 text-xs text-fd-muted-foreground">
|
||||
<IconBrandGithub aria-hidden size={14} />@{profile.github}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="flex flex-wrap items-center gap-2 lg:justify-end" role="group" aria-label="Post actions">
|
||||
<MarkdownCopyButton markdownUrl={markdownUrl} />
|
||||
<ViewOptionsPopover
|
||||
markdownUrl={markdownUrl}
|
||||
githubUrl={`https://github.com/homarr-labs/homarr/blob/release/v2/apps/docs/blog/${post.info.path}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<DocsBody className="homarr-blog-body border-t pt-8">
|
||||
<InlineTOC className="xl:hidden" items={toc} />
|
||||
<MDX
|
||||
components={getMDXComponents({
|
||||
h1: ({ id, children, ...props }) =>
|
||||
bodyTitleId && id === bodyTitleId ? null : (
|
||||
<h2 id={id} {...props}>
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
})}
|
||||
/>
|
||||
</DocsBody>
|
||||
</article>
|
||||
<BlogToc items={toc} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return posts.entries.map((post) => ({ slug: getPostSegments(post) }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const post = getPostBySegments(slug);
|
||||
if (!post) notFound();
|
||||
|
||||
const image = getPostUrl(post) === "/blog/2026/09/03/homarr-2.0" ? homarrV2SocialImage : undefined;
|
||||
|
||||
return pageMetadata({
|
||||
title: post.title,
|
||||
description: post.description ?? (await getPostExcerpt(post)).slice(0, 180),
|
||||
path: getPostUrl(post),
|
||||
authors: post.authors,
|
||||
publishedTime: post.date,
|
||||
image: image
|
||||
? {
|
||||
url: image.src,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
alt: `${post.title} release overview`,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { StaticRedirect } from "@/components/static-redirect";
|
||||
import { canonicalUrl } from "@/lib/metadata";
|
||||
import { getPostUrl, posts } from "@/lib/source";
|
||||
|
||||
function latestPost() {
|
||||
const post = posts.entries.toSorted((left, right) => right.date.localeCompare(left.date))[0];
|
||||
if (!post) throw new Error("The blog redirect requires a published article");
|
||||
return post;
|
||||
}
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
const post = latestPost();
|
||||
return {
|
||||
title: post.title,
|
||||
alternates: { canonical: canonicalUrl(getPostUrl(post)) },
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
}
|
||||
|
||||
export default function BlogIndexPage() {
|
||||
const post = latestPost();
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-4xl px-6 py-12">
|
||||
<h1 className="text-3xl font-bold">Latest Homarr news</h1>
|
||||
<StaticRedirect href={`${getPostUrl(post)}/`} label={post.title} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Carbon } from "@/components/carbon";
|
||||
import { HomeLayout } from "fumadocs-ui/layouts/home";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { baseOptions } from "@/lib/layout.shared";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<HomeLayout {...baseOptions()}>
|
||||
<div className="flex min-h-[calc(100dvh-var(--fd-nav-height))] flex-col">
|
||||
<div className="flex-1">{children}</div>
|
||||
<Carbon placement="site-footer" />
|
||||
<SiteFooter />
|
||||
</div>
|
||||
</HomeLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { pageMetadata } from "@/lib/metadata";
|
||||
|
||||
import { SectionContainer } from "@/components/pages/home/container/section-container";
|
||||
import { HomeDataflow } from "@/components/pages/home/dataflow/dataflow";
|
||||
import { DocsGateway } from "@/components/pages/home/docs-gateway";
|
||||
import { MotionReelShowcase } from "@/components/pages/home/motion-reel-showcase";
|
||||
import HomepageFeatures from "@/components/pages/home/features/features";
|
||||
import HomeHero from "@/components/pages/home/hero/hero";
|
||||
import { AvailableIntegrations } from "@/components/pages/home/integrations/available-integrations";
|
||||
import HomepageUserReviews from "@/components/pages/home/review-list/review-list";
|
||||
|
||||
import styles from "../../pages/index.module.css";
|
||||
|
||||
export const metadata = pageMetadata({
|
||||
path: "/",
|
||||
title: "A simple, powerful dashboard for your server",
|
||||
description:
|
||||
"Simplify the management of your server with Homarr, a sleek, modern dashboard that puts all of your apps and services at your fingertips.",
|
||||
});
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<main className="overflow-hidden">
|
||||
<HomeHero />
|
||||
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
preserveAspectRatio="none"
|
||||
viewBox="0 0 1440 320"
|
||||
className={styles.heroWave}
|
||||
>
|
||||
<path
|
||||
fill="var(--ifm-color-primary)"
|
||||
d="M0,224L48,202.7C96,181,192,139,288,133.3C384,128,480,160,576,149.3C672,139,768,85,864,58.7C960,32,1056,32,1152,37.3C1248,43,1344,53,1392,58.7L1440,64L1440,0L0,0Z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<SectionContainer>
|
||||
<HomepageFeatures />
|
||||
</SectionContainer>
|
||||
<DocsGateway />
|
||||
<HomeDataflow />
|
||||
<MotionReelShowcase />
|
||||
<AvailableIntegrations />
|
||||
<SectionContainer>
|
||||
<HomepageUserReviews />
|
||||
</SectionContainer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { SearchPage } from "@/components/search-page";
|
||||
import { pageMetadata } from "@/lib/metadata";
|
||||
|
||||
export const metadata = {
|
||||
...pageMetadata({
|
||||
title: "Search documentation",
|
||||
description: "Search Homarr documentation, API reference, and project news.",
|
||||
path: "/search",
|
||||
}),
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return <SearchPage />;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { WorkshopAdminRoute } from "@/components/workshop/WorkshopRoutes";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Workshop moderation",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function WorkshopAdminPage() {
|
||||
return <WorkshopAdminRoute configuredWorkshopUrl={process.env.WORKSHOP_API_URL ?? process.env.WORKSHOP_URL ?? ""} />;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { pageMetadata } from "@/lib/metadata";
|
||||
|
||||
import { WorkshopListingFallback } from "@/components/workshop/WorkshopApp";
|
||||
import { WorkshopErrorBoundary } from "@/components/workshop/WorkshopErrorBoundary";
|
||||
import { WorkshopRoute } from "@/components/workshop/WorkshopRoutes";
|
||||
|
||||
export const metadata = pageMetadata({
|
||||
path: "/workshop",
|
||||
title: "Workshop",
|
||||
description: "Community custom CSS and custom widgets for Homarr.",
|
||||
});
|
||||
|
||||
export default function WorkshopPage() {
|
||||
const configuredWorkshopUrl = process.env.WORKSHOP_API_URL ?? process.env.WORKSHOP_URL ?? "";
|
||||
|
||||
return (
|
||||
<main className="marketplace min-h-[80vh] bg-background text-foreground">
|
||||
<WorkshopErrorBoundary>
|
||||
<WorkshopRoute configuredWorkshopUrl={configuredWorkshopUrl} />
|
||||
</WorkshopErrorBoundary>
|
||||
<noscript>
|
||||
<WorkshopListingFallback />
|
||||
</noscript>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { DocsPage, DocsTitle } from "fumadocs-ui/layouts/docs/page";
|
||||
|
||||
import { OpenAPIPage } from "@/components/openapi-page";
|
||||
import { pageMetadata } from "@/lib/metadata";
|
||||
import { apiSource } from "@/lib/openapi";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ slug: string[] }>;
|
||||
}
|
||||
|
||||
export default async function ApiPage({ params }: PageProps) {
|
||||
const { slug } = await params;
|
||||
const page = apiSource.getPage(slug);
|
||||
if (!page) notFound();
|
||||
|
||||
return (
|
||||
<DocsPage toc={page.data.toc} full>
|
||||
<DocsTitle>{page.data.title}</DocsTitle>
|
||||
<div className="homarr-api-reference">
|
||||
<OpenAPIPage {...page.data.getOpenAPIPageProps()} />
|
||||
</div>
|
||||
</DocsPage>
|
||||
);
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return apiSource.generateParams();
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const page = apiSource.getPage(slug);
|
||||
if (!page) notFound();
|
||||
|
||||
const title = page.data.title ?? "API reference";
|
||||
return pageMetadata({
|
||||
title,
|
||||
description: page.data.description ?? `Request parameters and responses for ${title} in the Homarr HTTP API.`,
|
||||
path: page.url,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { DocsLayout } from "fumadocs-ui/layouts/docs";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { apiSource } from "@/lib/openapi";
|
||||
import { baseOptions } from "@/lib/layout.shared";
|
||||
|
||||
export default function ApiReferenceLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<DocsLayout tree={apiSource.pageTree} {...baseOptions()}>
|
||||
{children}
|
||||
</DocsLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Carbon } from "@/components/carbon";
|
||||
import { IconArrowRight } from "@tabler/icons-react";
|
||||
import { pageMetadata } from "@/lib/metadata";
|
||||
import Link from "next/link";
|
||||
import { DocsPage } from "fumadocs-ui/layouts/docs/page";
|
||||
|
||||
import { apiSource } from "@/lib/openapi";
|
||||
|
||||
export const metadata = pageMetadata({
|
||||
path: "/api-reference",
|
||||
title: "API reference",
|
||||
description: "Interactive reference for Homarr's HTTP API.",
|
||||
});
|
||||
|
||||
export default function ApiReferenceIndexPage() {
|
||||
const pages = apiSource.getPages();
|
||||
|
||||
return (
|
||||
<DocsPage full breadcrumb={{ enabled: false }} footer={{ enabled: false }}>
|
||||
<header className="homarr-content-header max-w-2xl">
|
||||
<p className="text-sm font-medium text-fd-primary">OpenAPI</p>
|
||||
<h1 className="homarr-content-title mt-2">Homarr API reference</h1>
|
||||
<p className="homarr-content-lead">
|
||||
Browse request parameters and response schemas, enter your Homarr instance’s full URL in Server URL, then
|
||||
review examples or send a test request from your browser.
|
||||
</p>
|
||||
</header>
|
||||
<div className="mt-10 divide-y border-y">
|
||||
{pages.map((page) => (
|
||||
<Link key={page.url} href={page.url} className="group flex items-center gap-3 py-4 hover:bg-fd-accent/50">
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{page.data.title}</span>
|
||||
<IconArrowRight className="text-fd-muted-foreground" aria-hidden size={17} />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<Carbon />
|
||||
</DocsPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { getBreadcrumbItems } from "fumadocs-core/breadcrumb";
|
||||
import { createSearchAPI } from "fumadocs-core/search/server";
|
||||
import { structure } from "fumadocs-core/mdx-plugins/remark-structure";
|
||||
|
||||
import { apiSource } from "@/lib/openapi";
|
||||
import { getApiMarkdown } from "@/lib/openapi-markdown";
|
||||
import { getPageMarkdown, source } from "@/lib/source";
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
async function buildDocIndex(page: (typeof source)["$inferPage"]) {
|
||||
const markdown = await getPageMarkdown(page);
|
||||
const original = page.data.structuredData;
|
||||
const headings = new Set(original.headings.map(({ id }) => id));
|
||||
const resolved = structure(markdown.replace(/^(#{1,6} .+) \[#[^\]]+\]$/gm, "$1"));
|
||||
const contents = new Map(original.contents.map((content) => [`${content.heading}\0${content.content}`, content]));
|
||||
for (const content of resolved.contents) {
|
||||
// Generated metadata can contain subheadings that are not HTML anchors.
|
||||
let heading: string | undefined;
|
||||
if (content.heading && headings.has(content.heading)) heading = content.heading;
|
||||
contents.set(`${heading}\0${content.content}`, { ...content, heading });
|
||||
}
|
||||
|
||||
return {
|
||||
id: page.url,
|
||||
title: stringOrFallback(page.data.title, page.url),
|
||||
description: stringValue(page.data.description),
|
||||
url: page.url,
|
||||
breadcrumbs: getBreadcrumbItems(page.url, source.getPageTree(page.locale), { includeRoot: true })
|
||||
.map((item) => item.name)
|
||||
.filter((name): name is string => typeof name === "string"),
|
||||
structuredData: {
|
||||
headings: original.headings,
|
||||
contents: [...contents.values()],
|
||||
},
|
||||
locale: page.locale,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildApiIndex(page: (typeof apiSource)["$inferPage"]) {
|
||||
const markdown = getApiMarkdown(page);
|
||||
return {
|
||||
id: page.url,
|
||||
title: stringOrFallback(page.data.title, page.url),
|
||||
description: stringValue(page.data.description),
|
||||
url: page.url,
|
||||
breadcrumbs: ["API reference"],
|
||||
structuredData: {
|
||||
headings: [],
|
||||
contents: [{ content: markdown, heading: undefined }],
|
||||
},
|
||||
locale: page.locale,
|
||||
};
|
||||
}
|
||||
|
||||
export const searchAPI = createSearchAPI("advanced", {
|
||||
language: "english",
|
||||
indexes: async () => [
|
||||
...(await Promise.all(source.getPages().map(buildDocIndex))),
|
||||
...(await Promise.all(apiSource.getPages().map(buildApiIndex))),
|
||||
],
|
||||
});
|
||||
|
||||
export const GET = searchAPI.staticGET;
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
if (typeof value === "string") return value;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function stringOrFallback(value: unknown, fallback: string) {
|
||||
return stringValue(value) ?? fallback;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Feed } from "feed";
|
||||
|
||||
import { getPostExcerpt, getPostUrl, posts } from "@/lib/source";
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
export async function GET() {
|
||||
const siteUrl = (process.env.HOMARR_WEBSITE_URL ?? "https://homarr.dev").replace(/\/$/, "");
|
||||
const feed = new Feed({
|
||||
title: "Homarr blog",
|
||||
description: "Release notes, migration guides, and project updates from Homarr.",
|
||||
id: `${siteUrl}/blog`,
|
||||
link: `${siteUrl}/blog`,
|
||||
language: "en",
|
||||
favicon: `${siteUrl}/img/favicon.png`,
|
||||
feedLinks: { rss: `${siteUrl}/blog/rss.xml` },
|
||||
copyright: `Homarr contributors ${new Date().getUTCFullYear()}`,
|
||||
});
|
||||
|
||||
for (const post of posts.entries.toSorted((left, right) => right.date.localeCompare(left.date))) {
|
||||
const link = `${siteUrl}${getPostUrl(post)}`;
|
||||
feed.addItem({
|
||||
title: post.title,
|
||||
id: link,
|
||||
link,
|
||||
date: new Date(post.date),
|
||||
author: post.authors.map((name) => ({ name })),
|
||||
description: await getPostExcerpt(post),
|
||||
category: post.tags?.map((name) => ({ name })),
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(feed.rss2(), {
|
||||
headers: { "Content-Type": "application/rss+xml; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Carbon } from "@/components/carbon";
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { findNeighbour } from "fumadocs-core/page-tree";
|
||||
import {
|
||||
DocsBody,
|
||||
DocsDescription,
|
||||
EditOnGitHub,
|
||||
DocsPage,
|
||||
DocsTitle,
|
||||
MarkdownCopyButton,
|
||||
ViewOptionsPopover,
|
||||
} from "fumadocs-ui/layouts/docs/page";
|
||||
import { createRelativeLink } from "fumadocs-ui/mdx";
|
||||
|
||||
import { pageMetadata } from "@/lib/metadata";
|
||||
import { getPageDescription } from "@/lib/page-description";
|
||||
import { getMDXComponents } from "@/components/mdx";
|
||||
import { getPageMarkdownUrl, source } from "@/lib/source";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ slug?: string[] }>;
|
||||
}
|
||||
|
||||
export default async function Page({ params }: PageProps) {
|
||||
const { slug } = await params;
|
||||
const page = source.getPage(slug);
|
||||
if (!page) notFound();
|
||||
|
||||
const MDX = page.data.body;
|
||||
const markdownUrl = getPageMarkdownUrl(page).url;
|
||||
const sourcePath = `apps/docs/docs/${page.path}`;
|
||||
const hasLegacyBodyTitle = !page.data.hide_title && page.data.toc[0]?.depth === 1;
|
||||
const bodyTitleId = hasLegacyBodyTitle ? decodeURIComponent(page.data.toc[0].url.slice(1)) : undefined;
|
||||
const toc = hasLegacyBodyTitle ? page.data.toc.filter((item) => item.depth !== 1) : page.data.toc;
|
||||
const isInstallationGuide =
|
||||
page.slugs.length > 2 && page.slugs[0] === "getting-started" && page.slugs[1] === "installation";
|
||||
const footerItems = isInstallationGuide
|
||||
? {
|
||||
...findNeighbour(source.pageTree, page.url),
|
||||
next: {
|
||||
name: "After the installation",
|
||||
description: "Complete onboarding and configure your first board.",
|
||||
url: "/docs/getting-started/after-the-installation",
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
let carbonPlacement: "banner" | "inline" = "inline";
|
||||
if (page.data.full) carbonPlacement = "banner";
|
||||
|
||||
return (
|
||||
<DocsPage
|
||||
toc={toc}
|
||||
full={page.data.full}
|
||||
tableOfContent={{
|
||||
enabled: !page.data.full,
|
||||
footer: <Carbon placement="toc" />,
|
||||
container: { className: "homarr-docs-toc" },
|
||||
}}
|
||||
footer={footerItems ? { items: footerItems } : undefined}
|
||||
>
|
||||
{!page.data.hide_title && (
|
||||
<>
|
||||
<DocsTitle id={bodyTitleId} className="homarr-docs-title">
|
||||
{page.data.title}
|
||||
</DocsTitle>
|
||||
{page.data.description && (
|
||||
<DocsDescription className="homarr-docs-description mb-0">{page.data.description}</DocsDescription>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="homarr-page-actions" role="group" aria-label="Page actions">
|
||||
<MarkdownCopyButton markdownUrl={markdownUrl} />
|
||||
<ViewOptionsPopover
|
||||
markdownUrl={markdownUrl}
|
||||
githubUrl={`https://github.com/homarr-labs/homarr/blob/release/v2/${sourcePath}`}
|
||||
/>
|
||||
</div>
|
||||
<DocsBody>
|
||||
<MDX
|
||||
components={getMDXComponents({
|
||||
a: createRelativeLink(source, page),
|
||||
...(!page.data.hide_title && {
|
||||
h1: ({ id, children, ...props }) =>
|
||||
bodyTitleId && id === bodyTitleId ? null : (
|
||||
<h2 id={id} {...props}>
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
}),
|
||||
})}
|
||||
/>
|
||||
</DocsBody>
|
||||
<Carbon placement={carbonPlacement} />
|
||||
<div className="mt-8 border-t pt-6">
|
||||
<EditOnGitHub href={`https://github.com/homarr-labs/homarr/edit/release/v2/${sourcePath}`}>
|
||||
Edit this page
|
||||
</EditOnGitHub>
|
||||
</div>
|
||||
</DocsPage>
|
||||
);
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return source.generateParams();
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const page = source.getPage(slug);
|
||||
if (!page) notFound();
|
||||
|
||||
return pageMetadata({
|
||||
title: page.data.title,
|
||||
description: await getPageDescription(page),
|
||||
path: page.url,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Carbon } from "@/components/carbon";
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { DocsBody, DocsPage, DocsTitle } from "fumadocs-ui/layouts/docs/page";
|
||||
|
||||
import { StaticRedirect } from "@/components/static-redirect";
|
||||
import { canonicalUrl } from "@/lib/metadata";
|
||||
import { source } from "@/lib/source";
|
||||
|
||||
// Docusaurus generated these URLs from category labels, not directory names.
|
||||
const categories: Record<string, string> = {
|
||||
advanced: "advanced",
|
||||
"developer-guides": "advanced/development/getting-started",
|
||||
community: "community",
|
||||
"getting-started": "getting-started",
|
||||
installation: "getting-started/installation",
|
||||
integrations: "integrations",
|
||||
management: "management",
|
||||
widgets: "widgets",
|
||||
};
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ category: string }>;
|
||||
}
|
||||
|
||||
async function getCategoryPage({ params }: PageProps) {
|
||||
const { category } = await params;
|
||||
const destination = Object.hasOwn(categories, category) && categories[category];
|
||||
if (!destination) notFound();
|
||||
const page = source.getPage(destination.split("/"));
|
||||
if (!page) throw new Error(`Missing documentation destination for legacy category: ${category}`);
|
||||
return page;
|
||||
}
|
||||
|
||||
export default async function LegacyCategoryPage(props: PageProps) {
|
||||
const page = await getCategoryPage(props);
|
||||
|
||||
return (
|
||||
<DocsPage tableOfContent={{ enabled: false }}>
|
||||
<DocsTitle>{page.data.title}</DocsTitle>
|
||||
<DocsBody>
|
||||
<p>This documentation has moved to a new address.</p>
|
||||
<StaticRedirect href={`${page.url}/`} label="Continue to the documentation" />
|
||||
</DocsBody>
|
||||
<Carbon />
|
||||
</DocsPage>
|
||||
);
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return Object.keys(categories).map((category) => ({ category }));
|
||||
}
|
||||
|
||||
export async function generateMetadata(props: PageProps): Promise<Metadata> {
|
||||
const page = await getCategoryPage(props);
|
||||
return {
|
||||
title: page.data.title,
|
||||
alternates: { canonical: canonicalUrl(page.url) },
|
||||
robots: { index: false, follow: true },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { DocsLayout } from "fumadocs-ui/layouts/docs";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { DocsSidebarFolder } from "@/components/docs/docs-sidebar-folder";
|
||||
import { baseOptions } from "@/lib/layout.shared";
|
||||
import { source } from "@/lib/source";
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<DocsLayout
|
||||
{...baseOptions()}
|
||||
tree={source.getPageTree()}
|
||||
sidebar={{ defaultOpenLevel: 1, components: { Folder: DocsSidebarFolder } }}
|
||||
>
|
||||
{children}
|
||||
</DocsLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
@import "tailwindcss";
|
||||
@import "fumadocs-ui/css/neutral.css";
|
||||
@import "fumadocs-ui/css/preset.css";
|
||||
@import "fumadocs-openapi/css/preset.css";
|
||||
|
||||
/* Scalar mounts its client at the end of body, outside Fumadocs' themed Test button. */
|
||||
html .scalar-app {
|
||||
--scalar-background-1: var(--color-fd-background);
|
||||
--scalar-background-2: var(--color-fd-card);
|
||||
--scalar-background-3: var(--color-fd-secondary);
|
||||
--scalar-color-1: var(--color-fd-foreground);
|
||||
--scalar-color-2: var(--color-fd-muted-foreground);
|
||||
--scalar-color-3: var(--color-fd-muted-foreground);
|
||||
--scalar-color-accent: var(--color-fd-primary);
|
||||
--scalar-border-color: var(--color-fd-border);
|
||||
--scalar-button-1: var(--color-fd-primary);
|
||||
--scalar-button-1-hover: color-mix(in srgb, var(--color-fd-primary) 85%, var(--color-fd-background));
|
||||
--scalar-button-1-color: var(--color-fd-primary-foreground);
|
||||
}
|
||||
|
||||
html.light .scalar-app {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
html.dark .scalar-app {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.scalar-app .scalar-container.scalar-client--open {
|
||||
z-index: 100;
|
||||
background: rgb(0 0 0 / 65%);
|
||||
}
|
||||
|
||||
.homarr-api-reference .not-prose:is(.light-mode, .dark-mode) + p {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
@source "../components/**/*.{ts,tsx}";
|
||||
@source "../../docs/**/*.{md,mdx}";
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--radius-sm: calc(var(--radius) * 0.75);
|
||||
--radius-md: var(--radius);
|
||||
--radius-lg: calc(var(--radius) * 1.25);
|
||||
--radius-xl: calc(var(--radius) * 1.5);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(0.985 0.006 25.7);
|
||||
--foreground: oklch(0.22 0 0);
|
||||
--card: oklch(0.997 0.002 25.7);
|
||||
--card-foreground: var(--foreground);
|
||||
--popover: var(--card);
|
||||
--popover-foreground: var(--foreground);
|
||||
--primary: #c62828;
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.96 0.012 25.7);
|
||||
--secondary-foreground: var(--foreground);
|
||||
--muted: oklch(0.965 0.008 25.7);
|
||||
--muted-foreground: oklch(0.46 0.012 25.7);
|
||||
--accent: oklch(0.955 0.025 25.7);
|
||||
--accent-foreground: oklch(0.46 0.17 25.7);
|
||||
--destructive: oklch(0.637 0.208 25.3);
|
||||
--border: oklch(0.88 0.012 25.7);
|
||||
--input: var(--border);
|
||||
--ring: var(--primary);
|
||||
--radius: 0.375rem;
|
||||
--ifm-color-primary: #c62828;
|
||||
--ifm-navbar-background-color: var(--card);
|
||||
--ifm-font-family-base: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--homarr-secondary-bg: oklch(0.965 0 0);
|
||||
--color-fd-background: var(--background);
|
||||
--color-fd-foreground: var(--foreground);
|
||||
--color-fd-card: var(--card);
|
||||
--color-fd-card-foreground: var(--card-foreground);
|
||||
--color-fd-popover: var(--popover);
|
||||
--color-fd-popover-foreground: var(--popover-foreground);
|
||||
--color-fd-primary: var(--primary);
|
||||
--color-fd-primary-foreground: var(--primary-foreground);
|
||||
--color-fd-secondary: var(--secondary);
|
||||
--color-fd-secondary-foreground: var(--secondary-foreground);
|
||||
--color-fd-muted: var(--muted);
|
||||
--color-fd-muted-foreground: var(--muted-foreground);
|
||||
--color-fd-accent: var(--accent);
|
||||
--color-fd-accent-foreground: var(--accent-foreground);
|
||||
--color-fd-border: color-mix(in oklch, var(--border) 82%, transparent);
|
||||
--color-fd-ring: var(--ring);
|
||||
--fd-layout-width: 96rem;
|
||||
--fd-page-width: 52rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--ifm-color-primary: #fa5252;
|
||||
--background: oklch(0.185 0.012 25.7);
|
||||
--foreground: oklch(0.965 0.004 25.7);
|
||||
--card: oklch(0.225 0.014 25.7);
|
||||
--card-foreground: var(--foreground);
|
||||
--popover: var(--card);
|
||||
--popover-foreground: var(--foreground);
|
||||
--primary: oklch(0.665 0.222 25.7);
|
||||
--primary-foreground: oklch(0.15 0.025 25.7);
|
||||
--secondary: oklch(0.265 0.016 25.7);
|
||||
--secondary-foreground: var(--foreground);
|
||||
--muted: oklch(0.255 0.014 25.7);
|
||||
--muted-foreground: oklch(0.74 0.014 25.7);
|
||||
--accent: oklch(0.31 0.055 25.7);
|
||||
--accent-foreground: oklch(0.88 0.09 25.7);
|
||||
--border: oklch(0.34 0.018 25.7);
|
||||
--input: var(--border);
|
||||
--ring: var(--primary);
|
||||
--ifm-navbar-background-color: var(--card);
|
||||
--homarr-secondary-bg: var(--card);
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--ifm-font-family-base);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.homarr-brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.homarr-brand-logo {
|
||||
width: 1.875rem;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#nd-nav {
|
||||
height: 4.25rem;
|
||||
}
|
||||
|
||||
#nd-nav > div {
|
||||
background-color: color-mix(in oklch, var(--color-fd-primary) 3%, var(--color-fd-background));
|
||||
border-bottom-color: color-mix(in oklch, var(--color-fd-primary) 18%, var(--color-fd-border));
|
||||
}
|
||||
|
||||
#nd-nav > div > div > nav {
|
||||
height: 4.25rem;
|
||||
}
|
||||
|
||||
#nd-nav a[data-active="true"],
|
||||
#nd-sidebar a[data-active="true"] {
|
||||
color: var(--color-fd-accent-foreground);
|
||||
}
|
||||
|
||||
#nd-page > div:first-child .text-fd-primary {
|
||||
color: var(--color-fd-foreground);
|
||||
}
|
||||
|
||||
#nd-sidebar {
|
||||
background-color: color-mix(in oklch, var(--color-fd-primary) 2.5%, var(--color-fd-background));
|
||||
border-color: color-mix(in oklch, var(--color-fd-primary) 14%, var(--color-fd-border));
|
||||
}
|
||||
|
||||
#nd-sidebar [data-radix-scroll-area-viewport],
|
||||
#nd-sidebar-mobile [data-radix-scroll-area-viewport] {
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
.homarr-docs-title {
|
||||
max-width: 22ch;
|
||||
font-size: clamp(2.15rem, 3.4vw, 2.75rem);
|
||||
font-weight: 750;
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
|
||||
.homarr-docs-description {
|
||||
max-width: 62ch;
|
||||
color: var(--color-fd-muted-foreground);
|
||||
}
|
||||
|
||||
.homarr-content-page {
|
||||
width: 100%;
|
||||
margin-inline: auto;
|
||||
padding: 3rem 1.25rem;
|
||||
}
|
||||
|
||||
.homarr-content-header {
|
||||
padding-bottom: 2.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.homarr-content-title {
|
||||
max-width: 24ch;
|
||||
font-size: clamp(2.25rem, 4vw, 3rem);
|
||||
font-weight: 750;
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.035em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.homarr-content-lead {
|
||||
max-width: 65ch;
|
||||
margin-top: 1rem;
|
||||
color: var(--color-fd-muted-foreground);
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.75;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.homarr-content-page {
|
||||
padding-inline: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.homarr-content-page {
|
||||
padding-block: 4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.homarr-page-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.25rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.homarr-blog-body :where(p, li) {
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.homarr-blog-body :where(img, video) {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.prose :where(h1, h2, h3) {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.prose :where(p, li) {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.prose a[data-card="true"] {
|
||||
border-color: color-mix(in oklch, var(--color-fd-primary) 12%, var(--color-fd-border));
|
||||
transition:
|
||||
transform 180ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
border-color 180ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
background-color 180ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.prose a[data-card="true"] > .not-prose:first-child {
|
||||
color: var(--color-fd-primary);
|
||||
background-color: color-mix(in oklch, var(--color-fd-primary) 9%, var(--color-fd-card));
|
||||
border-color: color-mix(in oklch, var(--color-fd-primary) 22%, var(--color-fd-border));
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.prose a[data-card="true"]:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: color-mix(in oklch, var(--color-fd-primary) 38%, var(--color-fd-border));
|
||||
}
|
||||
|
||||
@media print {
|
||||
#nd-docs-layout {
|
||||
--fd-sidebar-width: 0px !important;
|
||||
}
|
||||
|
||||
#nd-sidebar,
|
||||
.homarr-page-actions {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.prose a:not([data-card="true"]),
|
||||
footer a {
|
||||
text-decoration-line: underline;
|
||||
text-underline-offset: 0.16em;
|
||||
}
|
||||
|
||||
.dark .prose a code {
|
||||
color: var(--color-fd-primary);
|
||||
}
|
||||
|
||||
/* Place Carbon after the TOC, or below the page content when no TOC is visible. */
|
||||
.homarr-carbon {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
margin: 1.5rem auto 0;
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.homarr-carbon-banner {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.homarr-carbon:has(> div:empty) {
|
||||
margin-block: 0;
|
||||
}
|
||||
.homarr-carbon img {
|
||||
max-width: 100%;
|
||||
}
|
||||
.homarr-carbon-toc {
|
||||
display: none;
|
||||
}
|
||||
@media (min-width: 1280px) {
|
||||
.homarr-carbon-toc {
|
||||
display: block;
|
||||
}
|
||||
.homarr-carbon-inline {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Keep the ad in the side column on pages with no headings, without an empty TOC label. */
|
||||
.homarr-docs-toc:not(:has(a[href^="#"])) > :not(.homarr-carbon) {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import Script from "next/script";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { siteUrl } from "@/lib/metadata";
|
||||
import DocsMascot from "@/components/docs-mascot";
|
||||
import { Analytics } from "@/components/analytics";
|
||||
import { Provider } from "@/components/provider";
|
||||
|
||||
import "./global.css";
|
||||
|
||||
const kapaWebsiteId = process.env.KAPA_WEBSITE_ID ?? "1e4656f4-abeb-4343-bbae-1d8626f52378";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: siteUrl,
|
||||
title: {
|
||||
default: "Homarr documentation",
|
||||
template: "%s | Homarr documentation",
|
||||
},
|
||||
description: "Install, configure, operate, and extend Homarr.",
|
||||
keywords: ["Homarr", "dashboard", "self-hosted", "documentation"],
|
||||
icons: {
|
||||
icon: "/img/favicon.png",
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
colorScheme: "light dark",
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#171719" },
|
||||
],
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className="flex min-h-screen flex-col bg-fd-background text-fd-foreground antialiased">
|
||||
<Script src="/workshop-runtime-config.js" strategy="beforeInteractive" />
|
||||
<Provider>
|
||||
{children}
|
||||
{kapaWebsiteId && <DocsMascot />}
|
||||
</Provider>
|
||||
<Analytics />
|
||||
{kapaWebsiteId && (
|
||||
<Script
|
||||
id="kapa-widget"
|
||||
src="https://widget.kapa.ai/kapa-widget.bundle.js"
|
||||
strategy="afterInteractive"
|
||||
data-website-id={kapaWebsiteId}
|
||||
data-project-name="Homarr"
|
||||
data-project-color="#2B2B2B"
|
||||
data-project-logo={new URL("/img/favicon.png", siteUrl).href}
|
||||
data-modal-open-on-command-k="false"
|
||||
data-button-hide="true"
|
||||
data-color-scheme="auto"
|
||||
data-color-scheme-selector="html.dark"
|
||||
/>
|
||||
)}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { docsLlms, getPostLLMText, posts } from "@/lib/source";
|
||||
import { apiSource } from "@/lib/openapi";
|
||||
import { getApiMarkdown } from "@/lib/openapi-markdown";
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
export async function GET() {
|
||||
const pages = await Promise.all([
|
||||
docsLlms.full(),
|
||||
...posts.entries.map(getPostLLMText),
|
||||
...apiSource.getPages().map(getApiMarkdown),
|
||||
]);
|
||||
|
||||
return new Response(pages.join("\n\n"), {
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { apiSource } from "@/lib/openapi";
|
||||
import { getApiMarkdown, getApiMarkdownUrl } from "@/lib/openapi-markdown";
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ slug: string[] }>;
|
||||
}
|
||||
|
||||
export async function GET(_request: Request, { params }: RouteContext) {
|
||||
const { slug } = await params;
|
||||
const page = apiSource.getPage(slug.slice(0, -1));
|
||||
if (!page) notFound();
|
||||
|
||||
return new Response(getApiMarkdown(page), {
|
||||
headers: { "Content-Type": "text/markdown; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return apiSource.getPages().map((page) => ({ slug: getApiMarkdownUrl(page).segments }));
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { getPostBySegments, getPostLLMText, getPostSegments, posts } from "@/lib/source";
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ slug: string[] }>;
|
||||
}
|
||||
|
||||
export async function GET(_request: Request, { params }: RouteContext) {
|
||||
const { slug } = await params;
|
||||
const post = getPostBySegments(slug.slice(0, -1));
|
||||
if (!post) notFound();
|
||||
|
||||
return new Response(await getPostLLMText(post), {
|
||||
headers: { "Content-Type": "text/markdown; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return posts.entries.map((post) => ({ slug: [...getPostSegments(post), "content.md"] }));
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { docsLlms, getPageMarkdownUrl, source } from "@/lib/source";
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ slug?: string[] }>;
|
||||
}
|
||||
|
||||
export async function GET(_request: Request, { params }: RouteContext) {
|
||||
const { slug } = await params;
|
||||
const page = source.getPage(slug?.slice(0, -1));
|
||||
if (!page) notFound();
|
||||
|
||||
return new Response(await docsLlms.page(page), {
|
||||
headers: { "Content-Type": "text/markdown; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return source.getPages().map((page) => ({
|
||||
slug: getPageMarkdownUrl(page).segments,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { docsLlms, getPostMarkdownUrl, getPostUrl, posts } from "@/lib/source";
|
||||
import { apiSource } from "@/lib/openapi";
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
export async function GET() {
|
||||
const blog = posts.entries.map(
|
||||
(post) => `- [${post.title}](${getPostUrl(post)}): [Markdown](${getPostMarkdownUrl(post)})`,
|
||||
);
|
||||
const index = await docsLlms.index();
|
||||
const api = apiSource
|
||||
.getPages()
|
||||
.map((page) => `- [${page.data.title}](${page.url}): [Markdown](/llms.mdx${page.url}/content.md)`);
|
||||
const content = `${index}\n\n## Blog\n\n${blog.join("\n")}\n\n## HTTP API\n\n${api.join("\n")}\n`;
|
||||
|
||||
return new Response(content, {
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { HomeLayout } from "fumadocs-ui/layouts/home";
|
||||
|
||||
import { WorkshopNotFoundRouter } from "@/components/workshop/WorkshopNotFoundRouter";
|
||||
import { baseOptions } from "@/lib/layout.shared";
|
||||
|
||||
export default function NotFoundPage() {
|
||||
return (
|
||||
<HomeLayout {...baseOptions()}>
|
||||
<WorkshopNotFoundRouter configuredWorkshopUrl={process.env.WORKSHOP_API_URL ?? process.env.WORKSHOP_URL ?? ""} />
|
||||
</HomeLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
import { siteUrl } from "@/lib/metadata";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
},
|
||||
sitemap: new URL("/sitemap.xml", siteUrl).href,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
import { canonicalUrl } from "@/lib/metadata";
|
||||
import { apiSource } from "@/lib/openapi";
|
||||
import { getPostUrl, posts, source } from "@/lib/source";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return [
|
||||
{
|
||||
url: canonicalUrl("/"),
|
||||
changeFrequency: "weekly",
|
||||
priority: 1,
|
||||
},
|
||||
...source.getPages().map((page) => ({
|
||||
url: canonicalUrl(page.url),
|
||||
changeFrequency: "monthly" as const,
|
||||
priority: page.slugs.length <= 1 ? 0.8 : 0.7,
|
||||
})),
|
||||
...apiSource.getPages().map((page) => ({
|
||||
url: canonicalUrl(page.url),
|
||||
changeFrequency: "monthly" as const,
|
||||
priority: 0.6,
|
||||
})),
|
||||
...posts.entries.map((post) => ({
|
||||
url: canonicalUrl(getPostUrl(post)),
|
||||
lastModified: new Date(post.date),
|
||||
changeFrequency: "yearly" as const,
|
||||
priority: 0.5,
|
||||
})),
|
||||
{ url: canonicalUrl("/workshop"), changeFrequency: "daily", priority: 0.7 },
|
||||
{ url: canonicalUrl("/api-reference"), changeFrequency: "weekly", priority: 0.7 },
|
||||
{ url: canonicalUrl("/about-us"), changeFrequency: "monthly", priority: 0.5 },
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
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