docs: migrate documentation to Fumadocs

This commit is contained in:
ajnart committed 2026-09-10 20:39:26 +02:00
1 parent 6cca757493
commit 6704ef2c13
254 files changed
+6035 -11002

No files matched your search

@@ -0,0 +1,36 @@
import type { Metadata } from "next";
import { CodeContributorList } from "@/components/pages/about/code-contributors/code-contributor-list";
import { TranslationContributorList } from "@/components/pages/about/translations-contributors/translation-constributor-list";
import { SiteFooter } from "@/components/site-footer";
export const metadata: Metadata = {
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>
<SiteFooter />
</>
);
}
@@ -0,0 +1,63 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { InlineTOC } from "fumadocs-ui/components/inline-toc";
import { DocsBody, MarkdownCopyButton, ViewOptionsPopover } from "fumadocs-ui/layouts/docs/page";
import { getMDXComponents } from "@/components/mdx";
import { getPostBySegments, getPostMarkdownUrl, getPostSegments, getPostUrl, posts } from "@/lib/source";
interface PageProps {
params: Promise<{ slug: string[] }>;
}
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 toc = hasBodyTitle ? post.toc.filter((item) => item.depth !== 1) : post.toc;
return (
<main className="mx-auto w-full max-w-4xl px-5 py-14 sm:px-8">
<article>
<header className="border-b pb-8">
<time className="text-sm text-fd-muted-foreground" dateTime={post.date}>
{new Intl.DateTimeFormat("en", { dateStyle: "long", timeZone: "UTC" }).format(new Date(post.date))}
</time>
<h1 className="mt-3 text-4xl font-semibold tracking-tight text-balance">{post.title}</h1>
<p className="mt-3 text-sm text-fd-muted-foreground">By {post.authors.join(", ")}</p>
<div className="homarr-page-actions" 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>
</header>
<DocsBody className={hasBodyTitle ? "homarr-docs-body--hide-title pt-8" : "pt-8"}>
<InlineTOC items={toc} />
<MDX components={getMDXComponents()} />
</DocsBody>
</article>
</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();
return {
title: post.title,
alternates: { canonical: getPostUrl(post) },
authors: post.authors.map((name) => ({ name })),
};
}
+39
View File
@@ -0,0 +1,39 @@
import type { Metadata } from "next";
import Link from "next/link";
import { getPostUrl, posts } from "@/lib/source";
export const metadata: Metadata = {
title: "Blog",
description: "Release notes, migration guides, and project updates from Homarr.",
alternates: { types: { "application/rss+xml": "/blog/rss.xml" } },
};
export default function BlogIndexPage() {
const entries = posts.entries.toSorted((left, right) => right.date.localeCompare(left.date));
return (
<main className="mx-auto w-full max-w-4xl px-5 py-16 sm:px-8">
<header className="max-w-2xl border-b pb-10">
<p className="text-sm font-medium text-fd-primary">Homarr project</p>
<h1 className="mt-2 text-4xl font-semibold tracking-tight">Blog</h1>
<p className="mt-4 text-lg text-fd-muted-foreground">Release notes, migration guides, and project updates.</p>
</header>
<div className="divide-y">
{entries.map((post) => (
<article key={post.info.path} className="py-7">
<time className="text-sm text-fd-muted-foreground" dateTime={post.date}>
{new Intl.DateTimeFormat("en", { dateStyle: "long", timeZone: "UTC" }).format(new Date(post.date))}
</time>
<h2 className="mt-2 text-xl font-semibold tracking-tight">
<Link className="hover:text-fd-primary" href={getPostUrl(post)}>
{post.title}
</Link>
</h2>
<p className="mt-2 text-sm text-fd-muted-foreground">By {post.authors.join(", ")}</p>
</article>
))}
</div>
</main>
);
}
+8
View File
@@ -0,0 +1,8 @@
import { HomeLayout } from "fumadocs-ui/layouts/home";
import type { ReactNode } from "react";
import { baseOptions } from "@/lib/layout.shared";
export default function Layout({ children }: { children: ReactNode }) {
return <HomeLayout {...baseOptions()}>{children}</HomeLayout>;
}
+54
View File
@@ -0,0 +1,54 @@
import type { Metadata } from "next";
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 { DragAndDropShowcase } from "@/components/pages/home/drag-and-drop/drag-and-drop-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 { SiteFooter } from "@/components/site-footer";
import styles from "../../pages/index.module.css";
export const metadata: Metadata = {
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 />
<DragAndDropShowcase />
<AvailableIntegrations />
<SectionContainer>
<HomepageUserReviews />
</SectionContainer>
<SiteFooter />
</main>
);
}
@@ -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,25 @@
import type { Metadata } from "next";
import { WorkshopListingFallback } from "@/components/workshop/WorkshopApp";
import { WorkshopErrorBoundary } from "@/components/workshop/WorkshopErrorBoundary";
import { WorkshopRoute } from "@/components/workshop/WorkshopRoutes";
export const metadata: Metadata = {
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,38 @@
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 { 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>
<OpenAPIPage {...page.data.getOpenAPIPageProps()} />
</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();
return {
title: page.data.title,
description: page.data.description,
};
}
@@ -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>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { IconArrowRight } from "@tabler/icons-react";
import type { Metadata } from "next";
import Link from "next/link";
import { apiSource } from "@/lib/openapi";
export const metadata: Metadata = {
title: "API reference",
description: "Interactive reference for Homarr's HTTP API.",
};
export default function ApiReferenceIndexPage() {
const pages = apiSource.getPages();
return (
<main className="mx-auto w-full max-w-4xl px-5 py-14 sm:px-8">
<p className="text-sm font-medium text-fd-primary">OpenAPI</p>
<h1 className="mt-2 text-4xl font-semibold tracking-tight">Homarr API reference</h1>
<p className="mt-4 max-w-2xl text-lg leading-8 text-fd-muted-foreground">
Browse request parameters and response schemas, then generate and test examples from the source-of-truth schema.
</p>
<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>
</main>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { createFromSource } from "fumadocs-core/search/server";
import { source } from "@/lib/source";
export const revalidate = false;
export const { staticGET: GET } = createFromSource(source, {
language: "english",
});
+36
View File
@@ -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,94 @@
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 { 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 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;
return (
<DocsPage toc={toc} full={page.data.full} footer={footerItems ? { items: footerItems } : undefined}>
{!page.data.hide_title && (
<>
<DocsTitle 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 className={hasLegacyBodyTitle ? "homarr-docs-body--hide-title" : undefined}>
<MDX
components={getMDXComponents({
a: createRelativeLink(source, page),
})}
/>
</DocsBody>
<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 {
title: page.data.title,
description: page.data.description,
alternates: {
canonical: page.url,
},
};
}
+13
View File
@@ -0,0 +1,13 @@
import { DocsLayout } from "fumadocs-ui/layouts/docs";
import type { ReactNode } from "react";
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 }}>
{children}
</DocsLayout>
);
}
+242
View File
@@ -0,0 +1,242 @@
@import "tailwindcss";
@import "fumadocs-ui/css/neutral.css";
@import "fumadocs-ui/css/preset.css";
@import "fumadocs-openapi/css/preset.css";
@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));
}
.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-page-actions {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1.25rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.homarr-docs-body--hide-title > h1:first-of-type {
display: none;
}
.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);
}
+43
View File
@@ -0,0 +1,43 @@
import type { Metadata, Viewport } from "next";
import Script from "next/script";
import type { ReactNode } from "react";
import { Analytics } from "@/components/analytics";
import { Provider } from "@/components/provider";
import "./global.css";
const siteUrl = process.env.HOMARR_WEBSITE_URL ?? "https://homarr.dev";
export const metadata: Metadata = {
metadataBase: new URL(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="min-h-screen bg-fd-background text-fd-foreground antialiased">
<Script src="/workshop-runtime-config.js" strategy="beforeInteractive" />
<Provider>{children}</Provider>
<Analytics />
</body>
</html>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { getLLMText, getPostLLMText, posts, source } from "@/lib/source";
export const revalidate = false;
export async function GET() {
const pages = await Promise.all([...source.getPages().map(getLLMText), ...posts.entries.map(getPostLLMText)]);
return new Response(pages.join("\n\n"), {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
@@ -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 { getLLMText, 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 getLLMText(page), {
headers: { "Content-Type": "text/markdown; charset=utf-8" },
});
}
export function generateStaticParams() {
return source.getPages().map((page) => ({
slug: getPageMarkdownUrl(page).segments,
}));
}
+14
View File
@@ -0,0 +1,14 @@
import { llms } from "fumadocs-core/source";
import { getPostMarkdownUrl, getPostUrl, posts, source } from "@/lib/source";
export const revalidate = false;
export function GET() {
const blog = posts.entries.map((post) => `- [${post.title}](${getPostUrl(post)}): ${getPostMarkdownUrl(post)}`);
const content = `${llms(source).index()}\n\n## Blog\n\n${blog.join("\n")}\n`;
return new Response(content, {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
+12
View File
@@ -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>
);
}
+15
View File
@@ -0,0 +1,15 @@
import type { MetadataRoute } from "next";
const baseUrl = process.env.HOMARR_WEBSITE_URL ?? "https://homarr.dev";
export const dynamic = "force-static";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
},
sitemap: `${baseUrl}/sitemap.xml`,
};
}
+37
View File
@@ -0,0 +1,37 @@
import type { MetadataRoute } from "next";
import { apiSource } from "@/lib/openapi";
import { getPostUrl, posts, source } from "@/lib/source";
const baseUrl = process.env.HOMARR_WEBSITE_URL ?? "https://homarr.dev";
export const dynamic = "force-static";
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: baseUrl,
changeFrequency: "weekly",
priority: 1,
},
...source.getPages().map((page) => ({
url: `${baseUrl}${page.url}`,
changeFrequency: "monthly" as const,
priority: page.slugs.length <= 1 ? 0.8 : 0.7,
})),
...apiSource.getPages().map((page) => ({
url: `${baseUrl}${page.url}`,
changeFrequency: "monthly" as const,
priority: 0.6,
})),
...posts.entries.map((post) => ({
url: `${baseUrl}${getPostUrl(post)}`,
lastModified: new Date(post.date),
changeFrequency: "yearly" as const,
priority: 0.5,
})),
{ url: `${baseUrl}/workshop`, changeFrequency: "daily", priority: 0.7 },
{ url: `${baseUrl}/api-reference`, changeFrequency: "weekly", priority: 0.7 },
{ url: `${baseUrl}/blog`, changeFrequency: "monthly", priority: 0.6 },
];
}
+19
View File
@@ -0,0 +1,19 @@
"use client";
import posthog from "posthog-js";
import { useEffect } from "react";
export function Analytics() {
useEffect(() => {
posthog.init("phc_pWxeD1hbl4ip02JYReX1Crjkt5DhB3dduigirHMCtFE", {
api_host: "https://hog.homarr.dev",
ui_host: "https://eu.posthog.com",
defaults: "2026-01-30",
autocapture: true,
disable_session_recording: true,
advanced_disable_feature_flags: true,
});
}, []);
return null;
}
+4 -2
View File
@@ -1,9 +1,11 @@
import { useLocation } from "@docusaurus/router";
"use client";
import { usePathname } from "next/navigation";
import React, { useEffect } from "react";
export function Carbon() {
const ref = React.useRef<HTMLDivElement>(null!);
const location = useLocation();
const location = usePathname();
if (process.env.NODE_ENV === "development") {
return null;
}
@@ -1,6 +1,6 @@
.explorer {
--catalog-border: color-mix(in srgb, var(--ifm-color-emphasis-500) 42%, transparent);
--catalog-surface: color-mix(in srgb, var(--ifm-background-surface-color) 90%, var(--ifm-color-primary) 10%);
--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;
@@ -9,7 +9,7 @@
.header {
align-items: flex-start;
background: linear-gradient(135deg, color-mix(in srgb, var(--ifm-color-primary) 13%, transparent), transparent 65%);
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;
@@ -17,7 +17,7 @@
}
.eyebrow {
color: var(--ifm-color-primary);
color: var(--color-fd-primary);
font-size: 0.72rem;
font-weight: 750;
letter-spacing: 0.09em;
@@ -31,7 +31,7 @@
}
.summary {
color: var(--ifm-color-emphasis-700);
color: var(--color-fd-muted-foreground);
margin: 0.45rem 0 0;
max-width: 68ch;
}
@@ -52,9 +52,9 @@
.rawLink,
.expandButton,
.showMoreButton {
background: var(--ifm-color-primary);
border: 1px solid var(--ifm-color-primary);
color: var(--ifm-color-white);
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;
}
@@ -67,8 +67,8 @@
.rawLink:hover,
.expandButton:hover,
.showMoreButton:hover {
background: var(--ifm-color-primary-dark);
color: var(--ifm-color-white);
background: var(--color-fd-primary);
color: var(--color-fd-primary-foreground);
text-decoration: none;
}
@@ -87,7 +87,7 @@
gap: 1rem;
grid-template-columns: minmax(0, 1fr) auto;
padding: 1rem 1.4rem;
scroll-margin-top: calc(var(--ifm-navbar-height) + 1rem);
scroll-margin-top: calc(4.25rem + 1rem);
}
.globalProps h4,
@@ -96,7 +96,7 @@
}
.globalProps p {
color: var(--ifm-color-emphasis-700);
color: var(--color-fd-muted-foreground);
font-size: 0.86rem;
margin-top: 0.2rem;
}
@@ -114,14 +114,14 @@
}
.versions dt {
color: var(--ifm-color-emphasis-700);
color: var(--color-fd-muted-foreground);
font-size: 0.72rem;
font-weight: 650;
text-transform: uppercase;
}
.versions dd {
font-family: var(--ifm-font-family-monospace);
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.86rem;
margin: 0.12rem 0 0;
}
@@ -141,17 +141,17 @@
}
.filters label > span {
color: var(--ifm-color-emphasis-700);
color: var(--color-fd-muted-foreground);
font-size: 0.76rem;
font-weight: 700;
}
.filters input[type="search"],
.filters select {
background: var(--ifm-background-color);
background: var(--color-fd-background);
border: 1px solid var(--catalog-border);
border-radius: 9px;
color: var(--ifm-font-color-base);
color: var(--color-fd-foreground);
font: inherit;
min-height: 42px;
padding: 0.55rem 0.68rem;
@@ -164,7 +164,7 @@
.showMoreButton:focus-visible,
.resetButton:focus-visible,
.anchorLink:focus-visible {
outline: 3px solid color-mix(in srgb, var(--ifm-color-primary) 45%, transparent);
outline: 3px solid color-mix(in srgb, var(--color-fd-primary) 45%, transparent);
outline-offset: 2px;
}
@@ -193,7 +193,7 @@
.resetButton {
background: transparent;
border: 0;
color: var(--ifm-color-primary);
color: var(--color-fd-primary);
margin-left: auto;
padding: 0.35rem;
}
@@ -203,7 +203,7 @@
}
.componentCard {
scroll-margin-top: calc(var(--ifm-navbar-height) + 1rem);
scroll-margin-top: calc(4.25rem + 1rem);
}
.componentCard + .componentCard {
@@ -225,7 +225,7 @@
}
.componentNameRow h4 {
font-family: var(--ifm-font-family-monospace);
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 1.05rem;
margin: 0;
}
@@ -249,43 +249,43 @@
}
.badges span {
background: var(--ifm-color-emphasis-200);
background: var(--color-fd-muted);
border-radius: 999px;
color: var(--ifm-color-emphasis-800);
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(--ifm-font-color-base));
color: color-mix(in srgb, #2f9e44 78%, var(--color-fd-foreground));
}
.badges span[data-safety="wrapped"] {
background: color-mix(in srgb, var(--ifm-color-primary) 18%, transparent);
color: var(--ifm-color-primary-darkest);
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(--ifm-font-color-base));
color: color-mix(in srgb, #e03131 78%, var(--color-fd-foreground));
}
.expandButton {
background: transparent;
color: var(--ifm-color-primary);
color: var(--color-fd-primary);
flex: 0 0 auto;
padding: 0.42rem 0.7rem;
}
.expandButton:hover {
background: color-mix(in srgb, var(--ifm-color-primary) 12%, transparent);
color: var(--ifm-color-primary-dark);
background: color-mix(in srgb, var(--color-fd-primary) 12%, transparent);
color: var(--color-fd-primary);
}
.description,
.deniedReason {
color: var(--ifm-color-emphasis-700);
color: var(--color-fd-muted-foreground);
margin: -0.45rem 1.4rem 1rem;
}
@@ -312,7 +312,7 @@
}
.detailsGrid > section {
background: var(--ifm-background-color);
background: var(--color-fd-background);
border: 1px solid var(--catalog-border);
border-radius: 12px;
min-width: 0;
@@ -344,7 +344,7 @@
}
.propsTable th {
background: var(--ifm-background-color);
background: var(--color-fd-background);
position: sticky;
top: 0;
z-index: 1;
@@ -368,7 +368,7 @@
}
.muted {
color: var(--ifm-color-emphasis-600);
color: var(--color-fd-muted-foreground);
}
.rawComponent {
@@ -376,7 +376,7 @@
}
.rawComponent summary {
color: var(--ifm-color-primary);
color: var(--color-fd-primary);
cursor: pointer;
font-weight: 650;
}
@@ -393,8 +393,8 @@
.loading,
.error,
.empty {
background: var(--ifm-background-surface-color);
border: 1px solid var(--ifm-color-emphasis-300);
background: var(--color-fd-card);
border: 1px solid var(--color-fd-border);
border-radius: 12px;
margin: 1rem 0;
padding: 1rem;
@@ -1,5 +1,5 @@
import BrowserOnly from "@docusaurus/BrowserOnly";
import useBaseUrl from "@docusaurus/useBaseUrl";
"use client";
import { useEffect, useMemo, useState } from "react";
import { buildCustomJsxComponentUsageExample } from "@homarr/custom-widgets/catalog-example";
@@ -19,9 +19,7 @@ type CatalogProp = CustomJsxPropDescriptor;
const PAGE_SIZE = 40;
export function CustomJsxComponentReference() {
return (
<BrowserOnly fallback={<ComponentReferenceFallback />}>{() => <CustomJsxComponentReferenceClient />}</BrowserOnly>
);
return <CustomJsxComponentReferenceClient />;
}
function ComponentReferenceFallback() {
@@ -33,7 +31,7 @@ function ComponentReferenceFallback() {
}
function CustomJsxComponentReferenceClient() {
const catalogUrl = useBaseUrl("/custom-widgets/component-catalog-v1.json");
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("");
@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import { useColorMode } from "@docusaurus/theme-common";
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";
@@ -12,6 +13,7 @@ import {
} 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,
@@ -1,5 +1,3 @@
import BrowserOnly from "@docusaurus/BrowserOnly";
import {
BundledCustomWidgetGalleryClient,
CanonicalCustomWidgetExampleClient,
@@ -36,47 +34,17 @@ export interface CanonicalCustomWidgetExampleProps {
}
export function CanonicalCustomWidgetExample(props: CanonicalCustomWidgetExampleProps) {
return (
<BrowserOnly fallback={<CodeExampleFallback height={props.height} />}>
{() => <CanonicalCustomWidgetExampleClient {...props} />}
</BrowserOnly>
);
return <CanonicalCustomWidgetExampleClient {...props} />;
}
export function CustomWidgetCodeExample(props: CustomWidgetCodeExampleProps) {
return (
<BrowserOnly fallback={<CodeExampleFallback height={props.height} />}>
{() => <CustomWidgetCodeExampleClient {...props} />}
</BrowserOnly>
);
return <CustomWidgetCodeExampleClient {...props} />;
}
export function CustomWidgetCodeInput(props: CustomWidgetCodeInputProps) {
return (
<BrowserOnly fallback={<CodeExampleFallback height={props.height} />}>
{() => <CustomWidgetCodeInputClient {...props} />}
</BrowserOnly>
);
return <CustomWidgetCodeInputClient {...props} />;
}
export function BundledCustomWidgetGallery() {
return (
<BrowserOnly fallback={<CodeExampleFallback height="520px" />}>
{() => <BundledCustomWidgetGalleryClient />}
</BrowserOnly>
);
}
function CodeExampleFallback({ height }: { height?: string }) {
return (
<div
style={{
minHeight: height ?? "220px",
border: "1px solid var(--ifm-color-emphasis-300)",
borderRadius: "var(--ifm-global-radius)",
background: "var(--ifm-background-surface-color)",
}}
aria-busy="true"
/>
);
return <BundledCustomWidgetGalleryClient />;
}
@@ -0,0 +1,95 @@
"use client";
import { IconArrowUpRight, IconSearch, IconX } from "@tabler/icons-react";
import Link from "next/link";
import { useMemo, useState } from "react";
export interface DirectoryItem {
description?: string;
title: string;
url: string;
}
interface DirectoryGridProps {
items: DirectoryItem[];
label: string;
}
export function DirectoryGrid({ items, label }: DirectoryGridProps) {
const [query, setQuery] = useState("");
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"
type="text"
inputMode="search"
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={() => setQuery("")}
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) => (
<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 justify-between gap-4 p-4 text-fd-foreground hover:bg-fd-muted/60 hover:no-underline"
>
<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>
)}
</span>
<IconArrowUpRight
aria-hidden="true"
className="mt-0.5 shrink-0 text-fd-muted-foreground group-hover:text-fd-primary"
size={17}
/>
</Link>
</li>
))}
</ul>
) : (
<div className="rounded-lg border border-dashed p-6 text-center text-sm text-fd-muted-foreground">
No guides match “{query}”.
</div>
)}
</nav>
);
}
@@ -0,0 +1,26 @@
import { source } from "@/lib/source";
import { DirectoryGrid } from "./directory-grid";
const sectionLabels = {
integrations: "integration guides",
widgets: "widget guides",
} as const;
interface DocsDirectoryProps {
section: keyof typeof sectionLabels;
}
export function DocsDirectory({ section }: DocsDirectoryProps) {
const items = source
.getPages()
.filter((page) => page.slugs[0] === section && page.slugs.length === 2)
.map((page) => ({
description: page.data.description,
title: page.data.title,
url: page.url,
}))
.sort((a, b) => a.title.localeCompare(b.title));
return <DirectoryGrid items={items} label={sectionLabels[section]} />;
}
@@ -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,8 +1,7 @@
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 = {
apiKey: {
@@ -98,8 +97,8 @@ export const IntegrationSecrets = ({ secrets }: IntegrationSecretsProps) => {
<div className="flex flex-col gap-4 mt-6 w-full">
<div className="flex gap-6 rounded-xl border border-solid dark:border-[#333] border-[#e5e7eb] shadow-sm w-full items-center justify-between [&>*]:w-full">
<Tabs className="[&>li]:w-full [&>li]:justify-center">
{secrets.map((secret) => {
const key = secret.credentials.join("-");
{secrets.map((secret, index) => {
const key = secret.credentials.join("-") || `no-authentication-${index}`;
const Icon =
secret.credentials.map((value) => secretKinds[value]).find((value) => "icon" in value)?.icon ||
IconKeyOff;
@@ -1,5 +1,7 @@
import { getDocsHref } from "@/lib/docs-path";
import { WidgetDefinition } from "@site/src/types";
import { IconExternalLink, TablerIcon } from "@tabler/icons-react";
import { IconArrowRight, type TablerIcon } from "@tabler/icons-react";
import Link from "next/link";
interface IntegrationCapabilitesProps {
items: (
@@ -21,35 +23,37 @@ interface IntegrationCapabilitesProps {
export const IntegrationCapabilites = ({ items }: IntegrationCapabilitesProps) => {
return (
<div className="flex flex-col gap-4 mt-4 w-full">
<div className="flex flex-col gap-2">
<div className="not-prose mt-4 grid w-full gap-2">
<div className="grid gap-2">
{items.map((item) => {
const capability = "widget" in item ? item.widget : item.capability;
return (
<div
key={capability.name}
className="flex gap-6 rounded-xl border border-solid dark:border-[#333] border-[#e5e7eb] p-4 shadow-sm w-full items-center justify-between"
className="flex flex-col gap-4 rounded-xl border bg-fd-card p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex gap-6 items-center">
<div className="w-10 h-10 flex justify-center items-center bg-red-500/10 rounded-md">
<capability.icon size={24} stroke={1.5} className="stroke-red-500" />
<div className="flex min-w-0 items-start gap-4 sm:items-center">
<div className="flex size-10 shrink-0 items-center justify-center rounded-md bg-fd-primary/10 text-fd-primary">
<capability.icon aria-hidden="true" size={24} stroke={1.5} />
</div>
<div className="flex flex-col gap-0">
<span className="text-base font-bold">{capability.name}</span>
<span className="text-sm dark:text-[#999999] text-[#696969]">{capability.description}</span>
{item.note && <span className="text-xs text-yellow-500 mt-1">{item.note}</span>}
<div className="min-w-0">
<span className="block text-base font-semibold text-fd-foreground">{capability.name}</span>
<span className="mt-1 block text-sm leading-5 text-fd-muted-foreground">
{capability.description}
</span>
{item.note && <span className="mt-1 block text-xs text-fd-muted-foreground">{item.note}</span>}
</div>
</div>
<a
href={capability.path}
className="border border-solid border-[#e5e7eb] dark:border-[#333] p-2 py-1 rounded-md gap-2 flex justify-center items-center hover:no-underline hover:bg-slate-100 dark:hover:bg-gray-800"
<Link
href={getDocsHref(capability.path)}
className="inline-flex shrink-0 items-center justify-center gap-2 rounded-md border px-3 py-2 text-sm font-medium text-fd-foreground hover:bg-fd-muted hover:no-underline"
>
<IconExternalLink size={16} stroke={1.5} className="dark:stroke-white stroke-black" />
<span className="dark:text-white text-black font-medium text-sm">Details</span>
</a>
View guide
<IconArrowRight aria-hidden="true" size={16} stroke={1.5} />
</Link>
</div>
);
})}
+24
View File
@@ -0,0 +1,24 @@
import type { MDXComponents } from "mdx/types";
import { ImageZoom } from "fumadocs-ui/components/image-zoom";
import defaultMdxComponents from "fumadocs-ui/mdx";
import type { ComponentProps } from "react";
import Admonition from "./mdx/admonition";
import { TabItem, Tabs } from "./mdx/legacy-tabs";
export function getMDXComponents(components?: MDXComponents) {
return {
...defaultMdxComponents,
img: (props) => <ImageZoom {...(props as ComponentProps<typeof ImageZoom>)} />,
Alert: Admonition,
TabItem,
Tabs,
...components,
} satisfies MDXComponents;
}
export const useMDXComponents = getMDXComponents;
declare global {
type MDXProvidedComponents = ReturnType<typeof getMDXComponents>;
}
@@ -0,0 +1,26 @@
import { Callout } from "fumadocs-ui/components/callout";
import type { ReactNode } from "react";
interface AdmonitionProps {
children: ReactNode;
title?: ReactNode;
type?: "caution" | "danger" | "info" | "note" | "success" | "tip" | "warning";
}
const typeMap = {
caution: "warn",
danger: "error",
info: "info",
note: "info",
success: "success",
tip: "idea",
warning: "warn",
} as const;
export default function Admonition({ children, title, type = "note" }: AdmonitionProps) {
return (
<Callout title={title} type={typeMap[type]}>
{children}
</Callout>
);
}
@@ -0,0 +1,40 @@
"use client";
import { Tab, Tabs as FumadocsTabs, TabsList, TabsTrigger } from "fumadocs-ui/components/tabs";
import { Children, isValidElement, type ReactElement, type ReactNode } from "react";
interface TabItemProps {
children: ReactNode;
className?: string;
default?: boolean;
label: ReactNode;
value: string;
}
export function TabItem({ children }: TabItemProps) {
return children;
}
export function Tabs({ children, className }: { children: ReactNode; className?: string }) {
const tabs = Children.toArray(children).filter(isValidElement) as ReactElement<TabItemProps>[];
const defaultValue = tabs.find((tab) => tab.props.default)?.props.value ?? tabs[0]?.props.value;
return (
<FumadocsTabs className={className} defaultValue={defaultValue}>
<TabsList>
{tabs.map((tab) => (
<TabsTrigger key={tab.props.value} value={tab.props.value}>
{tab.props.label}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
<Tab key={tab.props.value} value={tab.props.value} className={tab.props.className}>
{tab.props.children}
</Tab>
))}
</FumadocsTabs>
);
}
export default Tabs;
+12
View File
@@ -0,0 +1,12 @@
import NextLink, { type LinkProps as NextLinkProps } from "next/link";
import type { AnchorHTMLAttributes } from "react";
type LinkProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> &
Omit<NextLinkProps, "href"> & {
href?: NextLinkProps["href"];
to?: NextLinkProps["href"];
};
export default function Link({ href, to, ...props }: LinkProps) {
return <NextLink href={href ?? to ?? "#"} {...props} />;
}
@@ -0,0 +1,7 @@
"use client";
import { createOpenAPIPage } from "fumadocs-openapi/ui";
export const OpenAPIPage = createOpenAPIPage({
showResponseSchema: true,
});
@@ -1,3 +1,5 @@
"use client";
import { useEffect, useState } from "react";
type Contributor = {
@@ -21,8 +23,14 @@ export const CodeContributorList = () => {
return (
<div className={"flex flex-wrap gap-3 argos-ignore"}>
{contributors.map((contributor: Contributor) => (
<div className={"flex flex-col items-center w-24"}>
<img className={"w-24 h-24 aspect-square rounded mb-2"} src={contributor.avatar_url} alt={""} />
<div key={contributor.login} className={"flex flex-col items-center w-24"}>
<img
className={"mb-2 aspect-square h-24 w-24 rounded"}
src={contributor.avatar_url}
alt={""}
loading="lazy"
decoding="async"
/>
<h6 className={"truncate text-nowrap max-w-full"}>{contributor.login}</h6>
</div>
))}
@@ -1,3 +1,5 @@
"use client";
import { useEffect, useState } from "react";
type Contributor = {
@@ -18,8 +20,14 @@ export const TranslationContributorList = () => {
return (
<div className={"flex flex-wrap gap-3 argos-ignore"}>
{contributors.map((contributor: Contributor) => (
<div className={"flex flex-col items-center w-24"}>
<img className={"w-24 h-24 aspect-square rounded mb-2"} src={contributor.avatarUrl} alt={""} />
<div key={contributor.username} className={"flex flex-col items-center w-24"}>
<img
className={"mb-2 aspect-square h-24 w-24 rounded"}
src={contributor.avatarUrl}
alt={""}
loading="lazy"
decoding="async"
/>
<h6 className={"truncate text-nowrap max-w-full"}>{contributor.username}</h6>
</div>
))}
@@ -1,9 +1,9 @@
.diagram {
--diagram-ink: var(--ifm-font-color-base);
--diagram-muted: var(--ifm-color-emphasis-700);
--diagram-panel: var(--ifm-background-surface-color);
--diagram-surface: var(--ifm-background-color);
--diagram-line: var(--ifm-color-emphasis-300);
--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;
@@ -34,7 +34,7 @@
.routeHeading > span {
display: grid;
place-items: center;
color: var(--ifm-color-primary);
color: var(--color-fd-primary);
}
.routeHeading strong {
@@ -77,7 +77,7 @@
}
.nodeLink:hover {
border-color: var(--ifm-color-primary);
border-color: var(--color-fd-primary);
color: var(--diagram-ink);
text-decoration: none;
}
@@ -95,7 +95,7 @@
flex: none;
place-items: center;
border: 1px solid var(--diagram-line);
color: var(--ifm-color-primary);
color: var(--color-fd-primary);
background: var(--diagram-panel);
}
@@ -165,8 +165,8 @@
}
.pillList a:hover {
border-color: var(--ifm-color-primary);
color: var(--ifm-color-primary);
border-color: var(--color-fd-primary);
color: var(--color-fd-primary);
text-decoration: none;
}
@@ -188,7 +188,7 @@
.definitionParts svg {
flex: none;
color: var(--ifm-color-primary);
color: var(--color-fd-primary);
}
.diagramNote {
@@ -221,7 +221,7 @@
.factList svg {
flex: none;
color: var(--ifm-color-primary);
color: var(--color-fd-primary);
}
.boardSource {
@@ -286,7 +286,7 @@
height: 1.65rem;
border: 1px solid var(--diagram-line);
border-radius: 0.4rem;
color: var(--ifm-color-primary);
color: var(--color-fd-primary);
background: var(--diagram-panel);
}
@@ -333,7 +333,7 @@
}
.miniTile:nth-child(2) {
border-color: var(--ifm-color-primary);
border-color: var(--color-fd-primary);
}
.mobileBoard {
@@ -1,6 +1,6 @@
import type { ReactNode } from "react";
import Link from "@docusaurus/Link";
import Link from "next/link";
import {
IconApi,
IconBox,
@@ -170,7 +170,7 @@ export function AssistantFlowDiagram() {
detail="Instance-wide provider; model may vary by conversation"
>
<div className={styles.pillList}>
<Link to="/docs/workshop/homarr-provider">Homarr</Link>
<Link href="/docs/workshop/homarr-provider">Homarr</Link>
<span>Hosted</span>
<span>Local</span>
<span>Custom</span>
@@ -259,7 +259,7 @@ export function WorkshopFlowDiagram() {
icon={<IconBuildingStore size={19} />}
title="Workshop"
detail="Source, screenshots, and revision"
href="pathname:///workshop"
href="/workshop"
/>
<FlowConnector label="inspect" animationDelay={350} />
<DiagramNode
@@ -361,7 +361,7 @@ function DiagramNode({
if (href) {
return (
<Link className={`${styles.node} ${styles.nodeLink}`} to={href}>
<Link className={`${styles.node} ${styles.nodeLink}`} href={href}>
{content}
</Link>
);
@@ -1,36 +1,34 @@
.diagram {
--diagram-ink: var(--ifm-font-color-base);
--diagram-muted: var(--ifm-color-emphasis-700);
--diagram-panel: var(--ifm-background-surface-color);
--diagram-line: var(--ifm-color-emphasis-300);
--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);
margin: 0;
padding: 1rem;
border: 1px solid var(--diagram-line);
border-radius: 0.75rem;
padding: 0;
border: 0;
color: var(--diagram-ink);
background: var(--diagram-panel);
background: transparent;
}
.lane {
display: grid;
grid-template-columns: minmax(8.5rem, 1.1fr) 2.75rem minmax(8.5rem, 1fr) 2.75rem minmax(8.5rem, 1fr) 2.75rem minmax(
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.35rem;
gap: 0.25rem;
}
.stage {
display: grid;
grid-template-rows: auto 1fr;
gap: 0.75rem;
gap: 0.55rem;
min-width: 0;
padding: 0.7rem;
border: 1px solid var(--diagram-line);
border-radius: 0.55rem;
padding: 0.45rem;
border: 0;
margin: 0;
background: var(--ifm-background-color);
background: transparent;
}
.stageHeading {
@@ -42,7 +40,7 @@
}
a.stageHeading:hover {
color: var(--ifm-color-primary);
color: var(--color-fd-primary);
text-decoration: none;
}
@@ -98,7 +96,7 @@ a.stageHeading:hover {
.service:hover,
.widget:hover {
border-color: var(--ifm-color-primary);
border-color: var(--color-fd-primary);
color: var(--diagram-ink);
text-decoration: none;
}
@@ -107,6 +105,7 @@ a.stageHeading:hover {
flex: none;
width: 1.25rem;
height: 1.25rem;
margin: 0;
}
.widget svg {
@@ -123,6 +122,7 @@ a.stageHeading:hover {
.widget span {
overflow: hidden;
font-size: 0.67rem;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -134,6 +134,7 @@ a.stageHeading:hover {
color: var(--diagram-muted);
background: transparent;
font-size: 0.56rem;
line-height: 1.3;
white-space: nowrap;
}
@@ -142,7 +143,7 @@ a.stageHeading:hover {
align-content: center;
justify-items: center;
gap: 0.35rem;
min-height: 5rem;
min-height: 4rem;
text-align: center;
}
@@ -160,8 +161,8 @@ a.stageHeading:hover {
display: grid;
align-content: center;
gap: 0.28rem;
min-height: 5rem;
padding: 0.42rem;
min-height: 0;
padding: 0.38rem;
border: 1px solid var(--diagram-line);
border-radius: 0.4rem;
background: var(--diagram-panel);
@@ -172,6 +173,7 @@ a.stageHeading:hover {
align-items: baseline;
justify-content: space-between;
gap: 0.3rem;
line-height: 1.2;
}
.calendarHeader strong {
@@ -194,6 +196,7 @@ a.stageHeading:hover {
color: var(--diagram-muted);
font-family: var(--font-mono);
font-size: 0.38rem;
line-height: 1.2;
text-align: center;
}
@@ -202,12 +205,13 @@ a.stageHeading:hover {
display: grid;
place-items: center;
min-width: 0;
aspect-ratio: 1;
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] {
@@ -1,6 +1,6 @@
import type { ReactNode } from "react";
import Link from "@docusaurus/Link";
import Link from "next/link";
import { IconCalendar, IconDownload, IconPlugConnected } from "@tabler/icons-react";
import { qBittorentIntegration } from "@site/docs/integrations/q-bittorent";
@@ -52,7 +52,7 @@ export function ConceptFlow() {
<Stage number="1" title="Service" detail="API endpoint">
<div className={styles.serviceList}>
{services.map((service) => (
<Link className={styles.service} to={service.href} key={service.name}>
<Link className={styles.service} href={service.href} key={service.name}>
<img src={service.iconUrl} alt="" />
<span>
<strong>{service.name}</strong>
@@ -75,12 +75,12 @@ export function ConceptFlow() {
<FlowConnector animationDelay={350} />
<Stage number="3" title="Widget" detail="Data and actions" href="/docs/category/widgets">
<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} to={widget.href} key={widget.name}>
<Link className={styles.widget} href={widget.href} key={widget.name}>
<Icon size={17} aria-hidden="true" />
<span>{widget.name}</span>
</Link>
@@ -142,7 +142,7 @@ function Stage({
return (
<section className={styles.stage}>
{href ? (
<Link className={styles.stageHeading} to={href}>
<Link className={styles.stageHeading} href={href}>
{heading}
</Link>
) : (
@@ -1,29 +1,38 @@
.overview {
--start-ink: var(--ifm-font-color-base);
--start-muted: var(--ifm-color-emphasis-700);
--start-panel: var(--ifm-background-surface-color);
--start-line: var(--ifm-color-emphasis-300);
--start-coral: #fa5252;
--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-coral: var(--color-fd-primary);
display: grid;
gap: 1.5rem;
margin: 1.5rem 0 3rem;
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-left: 1rem;
border-left: 3px solid var(--ifm-color-primary);
padding: 0;
}
.intro h2 {
margin: 0 0 0.4rem;
font-size: 1.5rem;
margin: 0 0 0.25rem;
font-size: 1.35rem;
line-height: 1.2;
}
.intro p {
max-width: 48rem;
margin: 0;
color: var(--start-muted);
line-height: 1.55;
line-height: 1.45;
}
.editor {
@@ -149,7 +158,7 @@
gap: 0.45rem;
min-height: 10rem;
padding: 1.1rem;
border: 1px solid var(--ifm-color-emphasis-400);
border: 1px solid var(--start-line);
border-radius: 0.9rem;
color: var(--start-ink);
background: var(--start-panel);
@@ -170,7 +179,7 @@
}
.installPath > span {
color: #c92f2f;
color: var(--color-fd-primary);
font-family: var(--font-mono);
font-size: 0.62rem;
font-weight: 700;
@@ -178,10 +187,6 @@
text-transform: uppercase;
}
:global([data-theme="dark"]) .installPath > span {
color: var(--ifm-color-primary-light);
}
.installPath strong {
font-size: 1rem;
}
@@ -199,7 +204,7 @@
}
.recommendedPath {
border-color: var(--ifm-color-primary);
border-color: var(--color-fd-primary);
background: var(--start-panel);
}
@@ -1,4 +1,4 @@
import Link from "@docusaurus/Link";
import Link from "next/link";
import { IconArrowRight } from "@tabler/icons-react";
import addItem from "@site/docs/getting-started/img/manage-board-header-choose-item.png";
@@ -34,13 +34,15 @@ const editingSteps = [
export function GettingStartedOverview() {
return (
<section className={styles.overview} aria-labelledby="getting-started-overview-title">
<header 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>
</header>
<div className={styles.overview}>
<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>
<ConceptFlow />
<ConceptFlow />
</section>
<section className={styles.editor} aria-labelledby="board-editor-title">
<div className={styles.editorHeading}>
@@ -48,7 +50,7 @@ export function GettingStartedOverview() {
<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>
<Link to="/docs/management/boards">
<Link href="/docs/management/boards">
Board docs <IconArrowRight size={15} aria-hidden="true" />
</Link>
</div>
@@ -64,13 +66,13 @@ export function GettingStartedOverview() {
</div>
</div>
<div className={styles.stepMedia}>
<img src={step.image} alt={step.alt} data-fit={step.fit} loading="lazy" />
<img src={step.image.src} alt={step.alt} data-fit={step.fit} loading="lazy" />
</div>
</li>
))}
</ol>
</section>
</section>
</div>
);
}
@@ -93,7 +95,7 @@ const installationPaths = [
eyebrow: "Platforms",
title: "NAS and hosting guides",
description: "Unraid, TrueNAS, Synology, Portainer, Proxmox, and more.",
href: "/docs/category/installation-1",
href: "/docs/getting-started/installation",
recommended: false,
},
];
@@ -106,7 +108,7 @@ export function InstallationPaths() {
if (path.recommended) className = `${className} ${styles.recommendedPath}`;
return (
<Link className={className} to={path.href} key={path.href}>
<Link className={className} href={path.href} key={path.href}>
<span>{path.eyebrow}</span>
<strong>{path.title}</strong>
<small>{path.description}</small>
@@ -6,7 +6,5 @@ interface SectionContainerProps {
}
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={"mx-auto w-full max-w-6xl px-6 sm:px-8" + (className ? " " + 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,35 +1,47 @@
import videoLight from "./showcase-light.mp4";
import videoDark from "./showcase-dark.mp4";
"use client";
import { IconClick } from "@tabler/icons-react";
import { SectionContainer } from "@site/src/components/pages/home/container/section-container";
import { useColorMode } from "@docusaurus/theme-common";
import { useEffect, useState } from "react";
import { SectionContainer } from "@/components/pages/home/container/section-container";
import { useColorMode } from "@/hooks/use-color-mode";
export const DragAndDropShowcase = () => {
const { colorMode } = useColorMode();
const video = colorMode === "dark" ? videoDark : videoLight;
const [mounted, setMounted] = useState(false);
const video = `/videos/home/showcase-${mounted && colorMode === "dark" ? "dark" : "light"}.mp4`;
useEffect(() => {
setMounted(true);
}, []);
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} />
<section className="my-20 sm:my-24" aria-labelledby="drag-drop-title">
<SectionContainer className="relative max-w-5xl">
<div className="mx-auto mb-8 max-w-2xl text-center">
<h2 id="drag-drop-title" className="m-0 text-3xl font-bold tracking-tight sm:text-5xl">
Easy setup using drag and drop
</h2>
</div>
<div className="absolute right-0 top-8 hidden translate-x-6 text-fd-primary/35 lg:block" aria-hidden="true">
<IconClick size={96} stroke={1.4} />
</div>
<div
className={"rounded-3xl overflow-hidden border-8 border-red-500 border-solid"}
className="overflow-hidden rounded-2xl border-4 border-fd-primary bg-fd-muted"
style={{ aspectRatio: "17.6/9" }}
>
<video
className={"w-full"}
className="h-full w-full object-cover"
src={video}
aria-label="Silent demonstration of moving and resizing Homarr dashboard widgets"
controls
playsInline
preload="metadata"
aria-label="Silent demonstration of moving and resizing Homarr dashboard widgets"
>
<track kind="captions" src="/media/homepage-drag-and-drop.vtt" srcLang="en" label="English" />
</video>
</div>
</SectionContainer>
</div>
</section>
);
};
@@ -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>
<h2 className="text-2xl font-extrabold text-gray-700 dark:text-gray-300 m-0">{props.title}</h2>
<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,4 +1,5 @@
import { translate } from "@docusaurus/Translate";
"use client";
import { IconBrandDocker, IconCpu, IconDeviceDesktopAnalytics } from "@tabler/icons-react";
import styles from "../../../../pages/index.module.css";
@@ -32,16 +33,16 @@ export const HeroCards = () => {
};
const heroLabels = {
systemResources: translate({ id: "homepage.preview.systemResources", message: "System resources" }),
online: translate({ id: "homepage.preview.online", message: "Online" }),
cpu: translate({ id: "homepage.preview.cpu", message: "CPU" }),
memory: translate({ id: "homepage.preview.memory", message: "Memory" }),
disk: translate({ id: "homepage.preview.disk", message: "Disk" }),
docker: translate({ id: "homepage.preview.docker", message: "Docker" }),
dockerSummary: translate({ id: "homepage.preview.dockerSummary", message: "2 running · 1 failed" }),
healthy: translate({ id: "homepage.preview.healthy", message: "Healthy" }),
running: translate({ id: "homepage.preview.running", message: "Running" }),
exited: translate({ id: "homepage.preview.exited", message: "Exited" }),
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 = () => (
@@ -1,67 +1,55 @@
import Link from "@docusaurus/Link";
import { translate } from "@docusaurus/Translate";
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 { HeroCards } from "./hero-cards";
export default function HomeHero() {
return (
<header className={clsx("hero hero--primary", styles.heroBanner, styles.hero)}>
<header className={clsx(styles.heroBanner, styles.hero)}>
<div className={styles.heroContent}>
<div className={styles.heroCopy}>
<h1 className={clsx("font-extrabold lg:text-7xl text-3xl", styles.heroTitle)}>
<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={styles.heroActions}>
<Link
data-attr="Install button"
className={"button button--secondary button--lg rounded-3xl dark:border-zinc-600 dark:bg-zinc-800"}
to="/docs/getting-started"
>
<div className={"flex items-center gap-3"}>
<span className={"dark:text-gray-200"}>Install</span>
<IconDownload className={"dark:text-gray-200"} size={20} />
</div>
<Link 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>
<span>Try Demo</span>
<IconExternalLink aria-hidden="true" size={20} />
</Link>
<Link
data-attr="Redirect to PikaPods"
className={`button bg-neutral-900 button--lg rounded-3xl dark:border-zinc-600 dark:bg-zinc-800`}
className={clsx(styles.heroButton, styles.partnerButton)}
to="https://www.pikapods.com/pods?run=homarr"
target="_blank"
rel="noopener noreferrer"
>
<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 with PikaPods</span>
</div>
<img src="/img/pictures/partner/pika-pods.svg" alt="PikaPods" height={20} />
<span>Host with PikaPods</span>
</Link>
</div>
</div>
<section
className={clsx(styles.heroPreview, "hover-animation")}
aria-label={translate({ id: "homepage.preview.label", message: "Example Homarr dashboard" })}
>
<section className={clsx(styles.heroPreview, "hover-animation")} aria-label="Example Homarr dashboard">
<HeroCards />
</section>
</div>
@@ -1,12 +1,12 @@
import dayjs from "dayjs";
import { CommonWidgetProps, WidgetCard } from "./card";
import clsx from "clsx";
import { CommonWidgetProps, WidgetCard } from "./card";
export const ClockWidget = ({ className }: CommonWidgetProps) => {
return (
<WidgetCard width={1} className={clsx("text-center gap-2", className)}>
<span className={"font-bold text-xl"}>{dayjs().format("HH:mm")}</span>
<span className="text-xs">{dayjs().format("dd, MMM. DD")}</span>
<span className="text-xl font-bold">14:33</span>
<span className="text-xs">Sun, Sep. 06</span>
</WidgetCard>
);
};
@@ -1,4 +1,5 @@
import { translate } from "@docusaurus/Translate";
"use client";
import { useEffect, useState } from "react";
import { CommonWidgetProps, WidgetCard } from "./card";
import clsx from "clsx";
@@ -6,8 +7,8 @@ import styles from "../../../../../pages/index.module.css";
const downloadQueue = ["family-photos.zip", "linux-isos.mkv", "server-backup.zip", "holiday-video.mp4"];
const downloadLabels = {
file: translate({ id: "homepage.preview.file", message: "File" }),
progress: translate({ id: "homepage.preview.progress", message: "Progress" }),
file: "File",
progress: "Progress",
};
export const DownloadsWidget = ({ className }: CommonWidgetProps) => {
@@ -57,21 +58,12 @@ export const DownloadsWidget = ({ className }: CommonWidgetProps) => {
return (
<tr key={file.filename}>
<td className="border-none text-nowrap p-2">{file.filename}</td>
<td
className="border-none p-2 w-full"
aria-label={translate(
{ id: "homepage.preview.downloadComplete", message: "{progress}% complete" },
{ progress: file.progress },
)}
>
<td className="border-none p-2 w-full" aria-label={`${file.progress}% complete`}>
<progress
className={styles.downloadProgress}
value={file.progress}
max={100}
aria-label={translate(
{ id: "homepage.preview.downloadProgress", message: "{filename} download progress" },
{ filename: file.filename },
)}
aria-label={`${file.filename} download progress`}
/>
</td>
</tr>
@@ -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(() => {
@@ -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,70 +1,44 @@
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 { 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, 12);
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">
Many 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"} aria-hidden="true">
<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}
alt=""
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}
alt=""
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>
))}
</div>
</div>
</div>
</section>
</SectionContainer>
);
};
@@ -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>
);
}
@@ -1,8 +1,13 @@
"use client";
import type React from "react";
import CodeBlock from "@theme/CodeBlock";
import Admonition from "@theme/Admonition";
import { IconRefresh } from "@tabler/icons-react";
import { DynamicCodeBlock } from "fumadocs-ui/components/dynamic-codeblock";
import { useEffect, useState } from "react";
import Admonition from "@/components/mdx/admonition";
import { Button } from "@/components/ui/button";
const generateRandomHex = (length = 64): string => {
const array = new Uint8Array(length / 2);
crypto.getRandomValues(array);
@@ -24,8 +29,9 @@ export const DockerInstallSnippet: React.FC = () => {
<p>
First, create a <code>docker-compose.yml</code> file with the following content:
</p>
<CodeBlock language="yml" title="docker-compose.yml" className={"argos-ignore"}>
{`#---------------------------------------------------------------------#
<DynamicCodeBlock
lang="yaml"
code={`#---------------------------------------------------------------------#
# Homarr - A simple, yet powerful dashboard for your server. #
#---------------------------------------------------------------------#
services:
@@ -41,14 +47,16 @@ services:
ports:
- '7575:7575'
`}
</CodeBlock>
<Admonition type="info">
Key provided above for the <code>SECRET_ENCRYPTION_KEY</code> environment variable is randomly generated using
your browser cryotography API. It will be different every time You can generate one yourself using{" "}
<code>openssl rand -hex 32</code>
<button className="px-1 mx-4" onClick={generateNewHex}>
/>
<Admonition type="info" title="Encryption key">
<p>
The <code>SECRET_ENCRYPTION_KEY</code> above is randomly generated by your browser. You can also generate one
with <code>openssl rand -hex 32</code>.
</p>
<Button type="button" variant="outline" size="sm" onClick={generateNewHex}>
<IconRefresh aria-hidden="true" />
Refresh key
</button>
</Button>
</Admonition>
</div>
);
+17
View File
@@ -0,0 +1,17 @@
"use client";
import { RootProvider } from "fumadocs-ui/provider/next";
import type { ReactNode } from "react";
import Search from "./search";
export function Provider({ children }: { children: ReactNode }) {
return (
<RootProvider
search={{ SearchDialog: Search }}
theme={{ attribute: ["class", "data-theme"], defaultTheme: "system", enableSystem: true }}
>
{children}
</RootProvider>
);
}
+35
View File
@@ -0,0 +1,35 @@
"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";
export default function Search(props: SharedProps) {
const { search, setSearch, query } = useDocsSearch({
client: staticClient(),
});
return (
<SearchDialog search={search} onSearchChange={setSearch} isLoading={query.isLoading} {...props}>
<SearchDialogOverlay />
<SearchDialogContent>
<SearchDialogHeader>
<SearchDialogIcon />
<SearchDialogInput />
<SearchDialogClose />
</SearchDialogHeader>
<SearchDialogList items={query.data !== "empty" ? query.data : null} />
</SearchDialogContent>
</SearchDialog>
);
}
+63
View File
@@ -0,0 +1,63 @@
import Image from "next/image";
import Link from "next/link";
export function SiteFooter() {
return (
<footer className="border-t bg-fd-muted/50">
<div className="mx-auto grid w-full max-w-6xl gap-10 px-6 py-12 sm:grid-cols-3 sm:px-8">
<div>
<h2 className="text-sm font-semibold">Documentation</h2>
<div className="mt-3 grid gap-2 text-sm text-fd-muted-foreground">
<Link className="hover:text-fd-foreground" href="/docs/getting-started">
Installation
</Link>
<Link className="hover:text-fd-foreground" href="/docs">
Browse all docs
</Link>
</div>
</div>
<div>
<h2 className="text-sm font-semibold">Community</h2>
<div className="mt-3 grid gap-2 text-sm text-fd-muted-foreground">
<a className="hover:text-fd-foreground" href="https://discord.com/invite/aCsmEV5RgA">
Discord
</a>
<a className="hover:text-fd-foreground" href="https://www.reddit.com/r/homarr/">
Reddit
</a>
<a className="hover:text-fd-foreground" href="https://github.com/homarr-labs/homarr">
GitHub
</a>
<a className="hover:text-fd-foreground" href="https://opencollective.com/homarr">
Donate
</a>
</div>
</div>
<div>
<h2 className="text-sm font-semibold">More</h2>
<div className="mt-3 grid gap-2 text-sm text-fd-muted-foreground">
<Link className="hover:text-fd-foreground" href="/workshop">
Workshop
</Link>
<Link className="hover:text-fd-foreground" href="/blog">
Blog
</Link>
<Link className="hover:text-fd-foreground" href="/about-us">
About us
</Link>
</div>
</div>
<div className="flex flex-col items-center gap-3 text-center sm:col-span-3">
<Image src="/img/logo.svg" alt="Homarr" width={58} height={40} />
<p className="text-sm font-medium">Your dashboard for the services you run.</p>
<p className="text-xs text-fd-muted-foreground">
© 2026 Homarr ·{" "}
<Link className="hover:text-fd-foreground" href="/docs/community/license">
License
</Link>
</p>
</div>
</div>
</footer>
);
}
@@ -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",
}}
/>
);
}
@@ -1,8 +1,8 @@
.connector {
--flow-accent: var(--ifm-color-primary);
--flow-accent: var(--color-fd-primary);
--flow-coral: #f17865;
--flow-head: var(--diagram-ink, var(--ifm-font-color-base));
--flow-line: var(--diagram-line, var(--ifm-color-emphasis-300));
--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;
@@ -19,8 +19,8 @@
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(--ifm-color-emphasis-700));
background: var(--diagram-panel, var(--ifm-background-surface-color));
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;
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { useColorMode } from "@docusaurus/theme-common";
import { useColorMode } from "@/hooks/use-color-mode";
import { Toaster as Sonner, type ToasterProps } from "sonner";
import { IconCircleCheck, IconInfoCircle, IconAlertTriangle, IconAlertOctagon, IconLoader } from "@tabler/icons-react";
@@ -1,6 +1,10 @@
import { useColorMode } from "@docusaurus/theme-common";
"use client";
import { useColorMode } from "@/hooks/use-color-mode";
import { getDocsHref } from "@/lib/docs-path";
import { IntegrationDefinition } from "@site/src/types";
import { IconExternalLink } from "@tabler/icons-react";
import { IconArrowRight } from "@tabler/icons-react";
import Link from "next/link";
import { getIntegrationIconUrl } from "../integrations/header";
interface WidgetIntegrationsProps {
@@ -14,36 +18,38 @@ export const WidgetIntegrations = ({ items }: WidgetIntegrationsProps) => {
const { isDarkTheme } = useColorMode();
return (
<div className="flex flex-col gap-4 mt-4 w-full">
<div className="flex flex-col gap-2">
<div className="not-prose mt-4 grid w-full gap-2">
<div className="grid gap-2">
{items.map((item) => (
<div
key={item.integration.name}
className="flex gap-6 rounded-xl border border-solid dark:border-[#333] border-[#e5e7eb] p-4 shadow-sm w-full items-center justify-between"
className="flex flex-col gap-4 rounded-xl border bg-fd-card p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex gap-6 items-center">
<div className="flex min-w-0 items-start gap-4 sm:items-center">
<img
width={40}
height={40}
src={getIntegrationIconUrl(item.integration, isDarkTheme)}
alt={`${item.integration.name} icon`}
className="w-10 h-10"
className="size-10 shrink-0 object-contain"
/>
<div className="flex flex-col gap-0">
<span className="text-base font-bold">{item.integration.name}</span>
<span className="text-sm dark:text-[#999999] text-[#696969]">{item.integration.description}</span>
{item.note && <span className="text-xs text-yellow-500 mt-1">{item.note}</span>}
<div className="min-w-0">
<span className="block text-base font-semibold text-fd-foreground">{item.integration.name}</span>
<span className="mt-1 block text-sm leading-5 text-fd-muted-foreground">
{item.integration.description}
</span>
{item.note && <span className="mt-1 block text-xs text-fd-muted-foreground">{item.note}</span>}
</div>
</div>
<a
href={item.integration.path}
className="border border-solid border-[#e5e7eb] dark:border-[#333] p-2 py-1 rounded-md gap-2 flex justify-center items-center hover:no-underline hover:bg-slate-100 dark:hover:bg-gray-800"
<Link
href={getDocsHref(item.integration.path)}
className="inline-flex shrink-0 items-center justify-center gap-2 rounded-md border px-3 py-2 text-sm font-medium text-fd-foreground hover:bg-fd-muted hover:no-underline"
>
<IconExternalLink size={16} stroke={1.5} className="dark:stroke-white stroke-black" />
<span className="dark:text-white text-black font-medium text-sm">Details</span>
</a>
View guide
<IconArrowRight aria-hidden="true" size={16} stroke={1.5} />
</Link>
</div>
))}
</div>
@@ -1,3 +1,5 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { IconCheck, IconEdit, IconMessageCircle, IconRefresh, IconSend, IconTrash, IconX } from "@tabler/icons-react";
@@ -21,7 +23,8 @@ import {
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { formatRelativeTime } from "./format";
@@ -121,8 +124,8 @@ export const CommentsSection = ({ submissionId, backend, currentUser, onRequireA
{loading && (
<div className="space-y-4" aria-label="Loading comments">
<div className="h-20 w-3/4 animate-pulse rounded-xl bg-muted" />
<div className="ml-auto h-20 w-2/3 animate-pulse rounded-xl bg-primary/10" />
<Skeleton className="h-20 w-3/4 rounded-xl" />
<Skeleton className="ml-auto h-20 w-2/3 rounded-xl bg-primary/10" />
</div>
)}
@@ -143,13 +146,15 @@ export const CommentsSection = ({ submissionId, backend, currentUser, onRequireA
)}
{!loading && !fetchError && rows.length === 0 && (
<div className="rounded-xl bg-muted/40 px-4 py-8 text-center">
<IconMessageCircle size={24} className="mx-auto text-muted-foreground" />
<p className="m-0 mt-2 text-sm font-medium">Start the discussion</p>
<p className="m-0 mt-1 text-sm text-muted-foreground">
Share a question or something you learned using this submission.
</p>
</div>
<Empty className="bg-muted/40 py-8">
<EmptyHeader>
<EmptyMedia variant="icon">
<IconMessageCircle />
</EmptyMedia>
<EmptyTitle>Start the discussion</EmptyTitle>
<EmptyDescription>Share a question or something you learned using this submission.</EmptyDescription>
</EmptyHeader>
</Empty>
)}
{!loading && !fetchError && rows.length > 0 && (
@@ -218,14 +223,17 @@ export const CommentsSection = ({ submissionId, backend, currentUser, onRequireA
}
>
{isEditing ? (
<div className="flex items-center gap-1.5">
<Input
className="min-w-48 bg-background text-foreground"
<div className="flex w-full min-w-0 items-end gap-1.5">
<Textarea
className="min-h-20 min-w-0 resize-y bg-background text-foreground"
aria-label="Edit comment"
value={editContent}
rows={3}
maxLength={2000}
onChange={(event) => setEditContent(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void handleUpdate(comment.id);
if ((event.metaKey || event.ctrlKey) && event.key === "Enter")
void handleUpdate(comment.id);
}}
/>
<Button
@@ -314,7 +322,10 @@ export const CommentsSection = ({ submissionId, backend, currentUser, onRequireA
<IconSend size={15} />
</Button>
</div>
<p className="m-0 mt-2 pl-12 text-xs text-muted-foreground">Press Ctrl or ⌘ + Enter to post.</p>
<div className="mt-2 flex items-center justify-between gap-4 pl-12 text-xs text-muted-foreground">
<p className="m-0">Press Ctrl or ⌘ + Enter to post.</p>
<span className="tabular-nums">{newComment.length}/2000</span>
</div>
{mutationError && (
<Alert variant="destructive" className="mt-3">
<IconMessageCircle />
+99 -135
View File
@@ -1,20 +1,15 @@
"use client";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import BrowserOnly from "@docusaurus/BrowserOnly";
import Head from "@docusaurus/Head";
import Link from "@docusaurus/Link";
import { useLocation } from "@docusaurus/router";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import Layout from "@theme/Layout";
import { usePathname } from "next/navigation";
import {
IconArrowBigDown,
IconArrowBigUp,
IconArrowLeft,
IconArrowRight,
IconBrandGithub,
IconCheck,
IconCopy,
IconDownload,
IconExternalLink,
IconFlag,
IconInfoCircle,
IconKey,
@@ -54,10 +49,13 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Toaster } from "@/components/ui/sonner";
import Link from "@/components/mdx/link";
import { CustomWidgetCodeExample, CustomWidgetCodeInput } from "../custom-widget-code";
import { validateSubmissionContent } from "@site/src/lib/workshop-schema";
import { cn, errorMessage, oauthErrorMessage } from "@site/src/lib/utils";
@@ -67,6 +65,7 @@ import { CodeBlock, DeleteConfirmButton, DetailSkeleton, ScreenshotGallery } fro
import { formatRelativeTime } from "./format";
import { ScreenshotEditor } from "./ScreenshotEditor";
import { WorkshopErrorBoundary } from "./WorkshopErrorBoundary";
import { WorkshopVoteControl } from "./WorkshopVoteControl";
import { downloadSubmissionJson, voteAndReconcile } from "./workshop-utils";
const typeLabels: Record<SubmissionType, string> = { customCss: "CSS", customWidget: "Widget" };
@@ -182,8 +181,8 @@ const WidgetSafetySummary = ({ widget }: { widget: HomarrCustomWidgetV2 }) => {
};
const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
const location = useLocation();
const submissionId = parseSubmissionId(location.pathname);
const pathname = usePathname();
const submissionId = parseSubmissionId(pathname ?? "");
const backend = useMemo(() => getWorkshopBackend(workshopUrl), [workshopUrl]);
const [submission, setSubmission] = useState<WorkshopSubmission | null>(null);
@@ -488,38 +487,39 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
if (error && !submission) {
return (
<div className="mx-auto flex min-h-[50vh] max-w-xl flex-col items-center justify-center gap-4 px-4 text-center">
<div className="flex size-11 items-center justify-center rounded-xl bg-destructive/10 text-destructive">
<IconX size={22} />
</div>
<div>
<h1 className="text-xl font-semibold">Submission could not be loaded</h1>
<p className="mt-1 text-sm text-muted-foreground">Check the Workshop service and try again.</p>
</div>
<div className="flex flex-wrap justify-center gap-2">
<Empty className="mx-auto min-h-[50vh] max-w-xl px-4">
<EmptyHeader>
<EmptyMedia className="flex size-11 items-center justify-center rounded-xl bg-destructive/10 text-destructive">
<IconX size={22} />
</EmptyMedia>
<EmptyTitle className="text-xl">Submission could not be loaded</EmptyTitle>
<EmptyDescription>Check the Workshop service and try again.</EmptyDescription>
</EmptyHeader>
<EmptyContent className="flex-row justify-center">
<Button variant="outline" onClick={() => setReloadKey((value) => value + 1)}>
<IconRefresh size={15} /> Try loading again
</Button>
<Button variant="ghost" nativeButton={false} render={<Link to="/workshop" />}>
Back to Workshop
</Button>
</div>
</div>
</EmptyContent>
</Empty>
);
}
if (notFound || !submission) {
return (
<div className="mx-auto max-w-4xl px-4 py-16 text-center">
<p className="text-lg font-medium">Submission not found</p>
<p className="mt-1 text-sm text-muted-foreground">This listing may have been removed or the link is invalid.</p>
<Link
to="/workshop"
className="mt-6 inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-muted-foreground hover:bg-muted hover:text-foreground"
>
<IconArrowLeft size={14} /> Back to Workshop
</Link>
</div>
<Empty className="mx-auto min-h-[50vh] max-w-xl px-4">
<EmptyHeader>
<EmptyTitle className="text-xl">Submission not found</EmptyTitle>
<EmptyDescription>This listing may have been removed or the link is invalid.</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button variant="outline" nativeButton={false} render={<Link to="/workshop" />}>
<IconArrowLeft /> Back to Workshop
</Button>
</EmptyContent>
</Empty>
);
}
@@ -545,7 +545,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
return (
<div className="mx-auto max-w-[90rem] px-4 pb-20 pt-8 sm:px-6 lg:px-8">
<Head>
<>
<title>{socialTitle}</title>
<meta name="description" content={socialDescription} />
<link rel="canonical" href={socialUrl} />
@@ -562,7 +562,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
<meta name="twitter:description" content={socialDescription} />
<meta name="twitter:image" content={socialImage} />
<meta name="twitter:image:alt" content={`${submission.title} preview`} />
</Head>
</>
<Toaster position="bottom-right" richColors />
<Link
to="/workshop"
@@ -626,38 +626,11 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
</div>
</div>
<div className="flex items-center gap-px rounded-lg border border-border bg-muted/40 p-1">
<button
type="button"
onClick={() => void handleVote(1)}
aria-label="Upvote"
aria-pressed={userVote?.value === 1}
className={cn(
"flex size-9 items-center justify-center rounded-md transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/50",
userVote?.value === 1 && "bg-primary/15 text-primary",
)}
>
<IconArrowBigUp size={18} />
</button>
<span
aria-live="polite"
className="min-w-7 text-center text-sm font-semibold tabular-nums text-foreground"
>
{score}
</span>
<button
type="button"
onClick={() => void handleVote(-1)}
aria-label="Downvote"
aria-pressed={userVote?.value === -1}
className={cn(
"flex size-9 items-center justify-center rounded-md transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/50",
userVote?.value === -1 && "bg-primary/15 text-primary",
)}
>
<IconArrowBigDown size={18} />
</button>
</div>
<WorkshopVoteControl
score={score}
userVote={userVote?.value}
onVote={(value) => void handleVote(value)}
/>
</div>
{widgetDefinition && <WidgetSafetySummary widget={widgetDefinition} />}
@@ -704,8 +677,8 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
)}
{submission.changelog && (
<section className="mt-8 border-l-2 border-primary/50 pl-4">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">What changed</p>
<section className="mt-8 rounded-lg bg-muted/50 px-4 py-3">
<h2 className="text-sm font-semibold">What changed</h2>
<p className="mt-1 text-sm leading-relaxed">{submission.changelog}</p>
</section>
)}
@@ -743,7 +716,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
</main>
<aside className="space-y-6 xl:sticky xl:top-24">
<section className="rounded-xl border border-border bg-card p-5 shadow-sm">
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<IconDownload size={17} />
@@ -761,18 +734,18 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
to="/docs/workshop/"
className="mt-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
>
Installation guide <IconExternalLink size={13} />
Installation guide <IconArrowRight size={13} />
</Link>
</section>
{widgetDefinition && (
<section className="rounded-xl border border-border bg-card p-5 shadow-sm">
<section className="rounded-xl border border-border bg-card p-5">
<h2 className="font-semibold">Widget details</h2>
<div className="mt-5 space-y-5">
<div>
<div className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<h3 className="mb-2 flex items-center gap-2 text-sm font-medium">
<IconServer size={14} /> API sources
</div>
</h3>
<div className="space-y-2.5">
{sources.map(([id, source]) => (
<div key={id} className="min-w-0">
@@ -790,9 +763,9 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
</div>
<div className="border-t border-border pt-4">
<div className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<h3 className="mb-2 flex items-center gap-2 text-sm font-medium">
<IconSettings size={14} /> Capabilities
</div>
</h3>
<div className="flex flex-wrap gap-1.5">
<Badge variant="secondary">
{requests.filter(([, request]) => request.kind === "query").length} queries
@@ -819,9 +792,9 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
{options.length > 0 && (
<div className="border-t border-border pt-4">
<div className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<h3 className="mb-2 flex items-center gap-2 text-sm font-medium">
<IconSettings size={14} /> Configurable options
</div>
</h3>
<div className="space-y-2">
{options.slice(0, 6).map(([id, option]) => (
<div key={id} className="flex items-start justify-between gap-3 text-xs">
@@ -837,9 +810,9 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
)}
<div className="border-t border-border pt-4">
<div className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<h3 className="mb-2 flex items-center gap-2 text-sm font-medium">
<IconKey size={14} /> Credentials
</div>
</h3>
{protectedSources.length > 0 ? (
<>
<p className="text-sm">
@@ -860,9 +833,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
{canManage && (
<section className="border-t border-border pt-5">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Manage submission
</p>
<h2 className="mb-2 text-sm font-semibold">Manage submission</h2>
<div className="grid gap-1">
{canEdit && (
<Button variant="ghost" size="sm" className="justify-start" onClick={openEdit}>
@@ -900,33 +871,35 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
<AlertDescription>{reportError}</AlertDescription>
</Alert>
)}
<label htmlFor="workshop-report-category" className="grid gap-1.5 text-sm">
Category
<Select value={reportCategory} onValueChange={(value) => setReportCategory(value as string)}>
<SelectTrigger id="workshop-report-category" className="h-10 w-full">
<SelectValue>{(value) => String(value).replace(/^./u, (letter) => letter.toUpperCase())}</SelectValue>
</SelectTrigger>
<SelectContent align="start">
<SelectItem value="outdated">Outdated or no longer working</SelectItem>
<SelectItem value="malicious">Malicious</SelectItem>
<SelectItem value="spam">Spam</SelectItem>
<SelectItem value="copyright">Copyright</SelectItem>
<SelectItem value="inappropriate">Inappropriate</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</label>
<label htmlFor="workshop-report-explanation" className="grid gap-1.5 text-sm">
Explanation
<Textarea
id="workshop-report-explanation"
value={reportExplanation}
onChange={(event) => setReportExplanation(event.target.value)}
placeholder="Explain what should be reviewed"
maxLength={1000}
rows={5}
/>
</label>
<FieldGroup className="gap-4">
<Field>
<FieldLabel htmlFor="workshop-report-category">Category</FieldLabel>
<Select value={reportCategory} onValueChange={(value) => setReportCategory(value as string)}>
<SelectTrigger id="workshop-report-category" className="h-10 w-full">
<SelectValue>{(value) => String(value).replace(/^./u, (letter) => letter.toUpperCase())}</SelectValue>
</SelectTrigger>
<SelectContent align="start">
<SelectItem value="outdated">Outdated or no longer working</SelectItem>
<SelectItem value="malicious">Malicious</SelectItem>
<SelectItem value="spam">Spam</SelectItem>
<SelectItem value="copyright">Copyright</SelectItem>
<SelectItem value="inappropriate">Inappropriate</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="workshop-report-explanation">Explanation</FieldLabel>
<Textarea
id="workshop-report-explanation"
value={reportExplanation}
onChange={(event) => setReportExplanation(event.target.value)}
placeholder="Explain what should be reviewed"
maxLength={1000}
rows={5}
/>
</Field>
</FieldGroup>
<DialogFooter>
<Button variant="ghost" onClick={() => setReportOpen(false)} disabled={reportPending}>
Cancel
@@ -972,26 +945,26 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
height="min(46vh, 460px)"
required
/>
<div className="flex flex-col gap-4">
<label htmlFor="workshop-edit-title" className="grid gap-1.5 text-sm">
Title
<FieldGroup className="gap-4">
<Field>
<FieldLabel htmlFor="workshop-edit-title">Title</FieldLabel>
<Input
id="workshop-edit-title"
value={editTitle}
onChange={(event) => setEditTitle(event.target.value)}
/>
</label>
<label htmlFor="workshop-edit-description" className="grid gap-1.5 text-sm">
Description
</Field>
<Field>
<FieldLabel htmlFor="workshop-edit-description">Description</FieldLabel>
<Textarea
id="workshop-edit-description"
value={editDescription}
onChange={(event) => setEditDescription(event.target.value)}
rows={5}
/>
</label>
<label htmlFor="workshop-edit-changelog" className="grid gap-1.5 text-sm">
Changelog
</Field>
<Field>
<FieldLabel htmlFor="workshop-edit-changelog">Changelog</FieldLabel>
<Textarea
id="workshop-edit-changelog"
value={editChangelog}
@@ -999,8 +972,8 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
placeholder="What changed?"
rows={4}
/>
</label>
</div>
</Field>
</FieldGroup>
</div>
<div className="border-t border-border pt-5">
<ScreenshotEditor
@@ -1041,25 +1014,16 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
);
};
export default function MarketplaceDetailPage() {
const { siteConfig } = useDocusaurusContext();
const configuredWorkshopUrl = (siteConfig.customFields?.workshopUrl as string | undefined) ?? "";
export default function MarketplaceDetailPage({ configuredWorkshopUrl = "" }: { configuredWorkshopUrl?: string }) {
useEffect(() => {
document.documentElement.removeAttribute("data-workshop-detail-loading");
}, []);
return (
<Layout title="Workshop" description="Community custom CSS and custom widgets for Homarr">
<main className="marketplace bg-background text-foreground min-h-[80vh]">
<BrowserOnly fallback={<DetailSkeleton />}>
{() => (
<WorkshopErrorBoundary>
<MarketplaceDetail workshopUrl={getRuntimeWorkshopApiUrl(configuredWorkshopUrl)} />
</WorkshopErrorBoundary>
)}
</BrowserOnly>
</main>
</Layout>
<main className="marketplace min-h-[80vh] bg-background text-foreground">
<WorkshopErrorBoundary>
<MarketplaceDetail workshopUrl={getRuntimeWorkshopApiUrl(configuredWorkshopUrl)} />
</WorkshopErrorBoundary>
</main>
);
}
@@ -3,12 +3,10 @@
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ScreenshotGallery } from "./DetailSections";
vi.mock("@docusaurus/theme-common", () => ({ useColorMode: () => ({ colorMode: "light" }) }));
let host: HTMLDivElement;
let root: Root;
@@ -1,7 +1,8 @@
"use client";
import React, { useEffect, useState } from "react";
import { useColorMode } from "@docusaurus/theme-common";
import { IconChevronLeft, IconChevronRight, IconTrash } from "@tabler/icons-react";
import { Highlight, themes } from "prism-react-renderer";
import { DynamicCodeBlock } from "fumadocs-ui/components/dynamic-codeblock";
import { Button } from "@/components/ui/button";
import {
@@ -20,9 +21,6 @@ import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@site/src/lib/utils";
import { clampScreenshotIndex } from "./workshop-utils";
const prismThemes = { light: themes.github, dark: themes.dracula } as const;
const colorModeKeys = { dark: "dark", light: "light" } as const;
export const DetailSkeleton = () => (
<div className="mx-auto max-w-[90rem] space-y-6 px-4 py-8 sm:px-6 lg:px-8">
<Skeleton className="h-4 w-24" />
@@ -60,22 +58,29 @@ export const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: stri
/>
{urls.length > 1 && (
<>
<button
<Button
type="button"
className="absolute left-3 top-1/2 flex size-11 -translate-y-1/2 items-center justify-center rounded-lg bg-background/85 opacity-80 shadow-sm transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring sm:size-9"
variant="secondary"
size="icon-lg"
className="absolute left-3 top-1/2 -translate-y-1/2 bg-background/90 opacity-85 shadow-sm hover:opacity-100"
onClick={() => setIdx((i) => (i - 1 + urls.length) % urls.length)}
aria-label="Previous screenshot"
>
<IconChevronLeft size={16} />
</button>
<button
</Button>
<Button
type="button"
className="absolute right-3 top-1/2 flex size-11 -translate-y-1/2 items-center justify-center rounded-lg bg-background/85 opacity-80 shadow-sm transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring sm:size-9"
variant="secondary"
size="icon-lg"
className="absolute right-3 top-1/2 -translate-y-1/2 bg-background/90 opacity-85 shadow-sm hover:opacity-100"
onClick={() => setIdx((i) => (i + 1) % urls.length)}
aria-label="Next screenshot"
>
<IconChevronRight size={16} />
</button>
</Button>
<span className="absolute right-3 bottom-3 rounded-md bg-background/90 px-2 py-1 text-xs font-medium tabular-nums shadow-sm">
{selectedIndex + 1} / {urls.length}
</span>
</>
)}
</div>
@@ -110,29 +115,9 @@ export const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: stri
);
};
export const CodeBlock = ({ content, language }: { content: string; language: string }) => {
const { colorMode } = useColorMode();
const theme = prismThemes[colorModeKeys[colorMode as keyof typeof colorModeKeys] ?? "light"];
return (
<Highlight theme={theme} code={content} language={language}>
{({ className, style, tokens, getLineProps, getTokenProps }) => (
<pre
className={cn(className, "overflow-auto rounded-lg border border-border p-4 text-sm leading-relaxed")}
style={style}
>
{tokens.map((line, i) => (
<div key={i} {...getLineProps({ line })}>
{line.map((token, key) => (
<span key={key} {...getTokenProps({ token })} />
))}
</div>
))}
</pre>
)}
</Highlight>
);
};
export const CodeBlock = ({ content, language }: { content: string; language: string }) => (
<DynamicCodeBlock lang={language} code={content} />
);
export const DeleteConfirmButton = ({ onConfirm, className }: { onConfirm: () => void; className?: string }) => {
return (
@@ -1,3 +1,5 @@
"use client";
import { useState } from "react";
import { IconPhoto, IconUpload, IconX } from "@tabler/icons-react";
@@ -1,3 +1,5 @@
"use client";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
IconAlertCircle,
@@ -26,7 +28,10 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Progress, ProgressLabel, ProgressValue } from "@/components/ui/progress";
import { Textarea } from "@/components/ui/textarea";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { cn, errorMessage } from "@/lib/utils";
import type { SubmitInput } from "./useWorkshop";
@@ -38,11 +43,8 @@ interface Props {
}
const steps = ["Type", "Details", "Media"] as const;
const navDirection = [-1, 1];
const stepAnimClass = ["submit-step-from-left", "submit-step-from-right"];
const backLabels = ["Cancel", "Back"];
const submitLabels = ["Publish submission", "Publishing…"];
const connectorClass = ["bg-border", "bg-primary"];
const dropOverlayClass = ["pointer-events-none opacity-0", "opacity-100"];
const placeholders: Record<SubmissionType, string> = {
@@ -66,7 +68,6 @@ const parseJsonObject = (json: string): Record<string, unknown> | null => {
export const SubmitForm = ({ onClose, onSubmit }: Props) => {
const [step, setStep] = useState(0);
const [direction, setDirection] = useState(1);
const [type, setType] = useState<SubmissionType | null>(null);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
@@ -81,13 +82,7 @@ export const SubmitForm = ({ onClose, onSubmit }: Props) => {
const canAdvance = [type !== null, title.trim().length >= 3 && content.trim().length > 0, true];
const goTo = useCallback(
(next: number) => {
setDirection(navDirection[Number(next > step)]);
setStep(next);
},
[step],
);
const goTo = useCallback((next: number) => setStep(next), []);
const setContentAndAutofill = useCallback((json: string, currentTitle: string, currentDesc: string) => {
setContent(json);
@@ -220,30 +215,10 @@ export const SubmitForm = ({ onClose, onSubmit }: Props) => {
<DialogDescription>Submit a custom CSS theme or widget to the Workshop.</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2">
{steps.map((label, i) => (
<React.Fragment key={label}>
{i > 0 && <div className={cn("h-px flex-1 transition-colors", connectorClass[Number(i <= step)])} />}
<button
type="button"
onClick={() => {
if (i < step) goTo(i);
}}
disabled={i > step}
aria-current={i === step ? "step" : undefined}
className={cn(
"flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium transition-all",
i === step && "bg-primary text-primary-foreground shadow-sm",
i < step && "bg-primary/10 text-primary cursor-pointer hover:bg-primary/20",
i > step && "bg-muted text-muted-foreground disabled:cursor-default",
)}
>
{i < step ? <IconCheck size={12} /> : <span className="tabular-nums">{i + 1}</span>}
{label}
</button>
</React.Fragment>
))}
</div>
<Progress value={((step + 1) / steps.length) * 100}>
<ProgressLabel>{steps[step]}</ProgressLabel>
<ProgressValue>{() => `Step ${step + 1} of ${steps.length}`}</ProgressValue>
</Progress>
{error && (
<Alert variant="destructive">
@@ -253,8 +228,8 @@ export const SubmitForm = ({ onClose, onSubmit }: Props) => {
</Alert>
)}
<div className="relative min-h-[280px] overflow-hidden">
<div key={step} className={cn("submit-step-enter", stepAnimClass[Number(direction > 0)])}>
<div className="relative min-h-[280px]">
<div key={step}>
{step === 0 && <StepType value={type} onChange={setType} onJsonDrop={handleJsonFileDrop} />}
{step === 1 && type && (
<StepDetails
@@ -282,6 +257,7 @@ export const SubmitForm = ({ onClose, onSubmit }: Props) => {
<DialogFooter className="sm:justify-between">
<Button
variant="ghost"
className="w-full sm:w-auto"
onClick={() => {
if (step === 0) onClose();
else goTo(step - 1);
@@ -290,14 +266,18 @@ export const SubmitForm = ({ onClose, onSubmit }: Props) => {
>
<IconChevronLeft size={14} /> {backLabels[Math.min(step, 1)]}
</Button>
<div className="flex items-center gap-2">
<div className="flex w-full items-center gap-2 sm:w-auto">
{step < 2 && (
<Button onClick={() => goTo(step + 1)} disabled={!canAdvance[step]}>
<Button className="w-full sm:w-auto" onClick={() => goTo(step + 1)} disabled={!canAdvance[step]}>
Next <IconChevronRight size={14} />
</Button>
)}
{step >= 2 && (
<Button onClick={() => void handleSubmit()} disabled={pending || !canAdvance[1]}>
<Button
className="w-full sm:w-auto"
onClick={() => void handleSubmit()}
disabled={pending || !canAdvance[1]}
>
{pending && <IconLoader2 size={14} className="animate-spin" />}
{submitLabels[Number(pending)]}
</Button>
@@ -337,40 +317,48 @@ const StepType = ({
return (
<div className="flex flex-col gap-3">
<div className="grid grid-cols-2 gap-3">
<ToggleGroup
value={value ? [value] : []}
onValueChange={(values) => {
const selected = values[0] as SubmissionType | undefined;
if (selected) onChange(selected);
}}
spacing={12}
className="grid w-full grid-cols-1 gap-3 sm:grid-cols-2"
aria-label="Submission type"
>
{typeCards.map(({ type, icon: Icon, label, desc }) => (
<button
type="button"
<ToggleGroupItem
key={type}
onClick={() => onChange(type)}
aria-pressed={value === type}
value={type}
aria-label={`${label}: ${desc}`}
className={cn(
"group relative flex min-h-48 flex-col items-center justify-center gap-3 rounded-xl border-2 p-6 text-center transition-colors hover:border-primary/50 hover:bg-primary/5",
value === type ? "border-primary bg-primary/10 ring-1 ring-primary/20" : "border-border bg-card",
"group relative h-auto min-h-36 w-full flex-col items-start justify-start gap-3 rounded-xl border p-4 text-left whitespace-normal transition-colors hover:border-primary/50 hover:bg-primary/5 sm:min-h-40",
"data-pressed:border-primary data-pressed:bg-primary/10 data-pressed:text-foreground data-pressed:ring-1 data-pressed:ring-primary/20",
)}
>
<div
className={cn(
"flex size-12 items-center justify-center rounded-xl transition-colors",
"flex size-10 items-center justify-center rounded-lg transition-colors",
value === type
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary",
)}
>
<Icon size={24} />
<Icon size={20} />
</div>
<div>
<p className="font-heading text-sm font-semibold">{label}</p>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">{desc}</p>
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">{desc}</p>
</div>
{value === type && (
<div className="absolute top-3 right-3 flex size-5 items-center justify-center rounded-full bg-primary text-primary-foreground">
<div className="absolute top-3 right-3 flex size-6 items-center justify-center rounded-full bg-primary text-primary-foreground">
<IconCheck size={12} />
</div>
)}
</button>
</ToggleGroupItem>
))}
</div>
</ToggleGroup>
{/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
<label
@@ -452,11 +440,11 @@ const StepDetails = ({
}
/>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label htmlFor="submit-title" className="text-xs font-medium text-muted-foreground">
Title *
</label>
<FieldGroup className="gap-4">
<Field>
<FieldLabel htmlFor="submit-title">
Title <span aria-hidden="true">*</span>
</FieldLabel>
<Input
id="submit-title"
value={title}
@@ -466,11 +454,9 @@ const StepDetails = ({
maxLength={100}
placeholder="My awesome theme"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="submit-description" className="text-xs font-medium text-muted-foreground">
Description
</label>
</Field>
<Field>
<FieldLabel htmlFor="submit-description">Description</FieldLabel>
<Textarea
id="submit-description"
value={description}
@@ -479,11 +465,9 @@ const StepDetails = ({
rows={5}
placeholder="A brief description of what this does"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="submit-changelog" className="text-xs font-medium text-muted-foreground">
Changelog
</label>
</Field>
<Field>
<FieldLabel htmlFor="submit-changelog">Changelog</FieldLabel>
<Textarea
id="submit-changelog"
value={changelog}
@@ -492,7 +476,7 @@ const StepDetails = ({
rows={4}
placeholder="What is included in this revision?"
/>
</div>
</div>
</Field>
</FieldGroup>
</div>
);
@@ -1,4 +1,5 @@
import React, { useEffect, useRef, useState } from "react";
"use client";
import { IconBrandGithub, IconChevronDown, IconExternalLink, IconLogout } from "@tabler/icons-react";
import { githubAvatarUrl, githubProfileUrl } from "@homarr/workshop/schema";
@@ -6,7 +7,14 @@ import type { WorkshopUser } from "@homarr/workshop/schema";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const avatarFallback = (name: string) => name.trim().slice(0, 1).toUpperCase() || "?";
@@ -16,100 +24,62 @@ interface WorkshopAccountMenuProps {
}
export const WorkshopAccountMenu = ({ user, onSignOut }: WorkshopAccountMenuProps) => {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const profileUrl = githubProfileUrl(user.name);
const avatarUrl = githubAvatarUrl(user.name);
const accountLabel = user.name ? `@${user.name}` : "Account";
useEffect(() => {
if (!open) return;
const closeOnOutsidePointer = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("pointerdown", closeOnOutsidePointer);
document.addEventListener("keydown", closeOnEscape);
return () => {
document.removeEventListener("pointerdown", closeOnOutsidePointer);
document.removeEventListener("keydown", closeOnEscape);
};
}, [open]);
return (
<div ref={rootRef} className="relative">
<Button
variant="ghost"
size="sm"
className="h-10 gap-2 px-2 sm:h-8"
aria-label="Workshop account menu"
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((value) => !value)}
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="sm" className="h-10 gap-2 px-2 sm:h-8" aria-label="Workshop account menu" />
}
>
<Avatar className="size-6">
{avatarUrl && <AvatarImage src={avatarUrl} alt="" />}
<AvatarFallback className="text-[10px]">{avatarFallback(user.name)}</AvatarFallback>
</Avatar>
<span className="max-w-36 truncate">{accountLabel}</span>
<IconChevronDown
size={13}
aria-hidden="true"
className={cn("text-muted-foreground transition-transform duration-150", open && "rotate-180")}
/>
</Button>
<IconChevronDown size={13} aria-hidden="true" className="text-muted-foreground" />
</DropdownMenuTrigger>
{open && (
<div
role="menu"
aria-label="Workshop account"
className="absolute right-0 z-50 mt-2 w-64 rounded-lg border border-border bg-popover p-1.5 text-popover-foreground shadow-md"
>
<div className="flex items-center gap-3 px-2 py-2">
<Avatar className="size-9">
{avatarUrl && <AvatarImage src={avatarUrl} alt="" />}
<AvatarFallback>{avatarFallback(user.name)}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{accountLabel}</p>
</div>
<DropdownMenuContent align="end" className="w-64" aria-label="Workshop account">
<DropdownMenuLabel className="flex items-center gap-3 px-2 py-2 font-normal text-foreground">
<Avatar className="size-9">
{avatarUrl && <AvatarImage src={avatarUrl} alt="" />}
<AvatarFallback>{avatarFallback(user.name)}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{accountLabel}</p>
<p className="text-xs text-muted-foreground">Workshop account</p>
</div>
</DropdownMenuLabel>
<div className="my-1 h-px bg-border" />
<DropdownMenuSeparator />
{profileUrl && (
<a
role="menuitem"
href={profileUrl}
target="_blank"
rel="noreferrer"
className="flex h-9 items-center gap-2 rounded-md px-2 text-sm text-foreground no-underline hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
onClick={() => setOpen(false)}
>
<IconBrandGithub size={16} aria-hidden="true" />
View GitHub profile
<IconExternalLink size={14} aria-hidden="true" className="ml-auto text-muted-foreground" />
</a>
)}
<button
type="button"
role="menuitem"
className="flex h-9 w-full items-center gap-2 rounded-md px-2 text-sm text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
onClick={() => {
setOpen(false);
onSignOut();
}}
{profileUrl && (
<DropdownMenuItem
render={
<a
href={profileUrl}
target="_blank"
rel="noreferrer"
className="no-underline"
aria-label="View GitHub profile"
/>
}
>
<IconLogout size={16} aria-hidden="true" />
Sign out
</button>
</div>
)}
</div>
<IconBrandGithub aria-hidden="true" />
View GitHub profile
<IconExternalLink aria-hidden="true" className="ml-auto text-muted-foreground" />
</DropdownMenuItem>
)}
<DropdownMenuItem variant="destructive" onClick={onSignOut}>
<IconLogout aria-hidden="true" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
@@ -1,3 +1,5 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
IconBrandGithub,
@@ -14,18 +16,23 @@ import { githubAvatarUrl, githubProfileUrl } from "@homarr/workshop/schema";
import type { WorkshopReport, WorkshopSubmissionSummary, WorkshopUser } from "@homarr/workshop/schema";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
import { Skeleton } from "@/components/ui/skeleton";
const avatarFallback = (name: string) => name.trim().slice(0, 1).toUpperCase() || "?";
@@ -39,7 +46,7 @@ export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) {
const [submissions, setSubmissions] = useState<WorkshopSubmissionSummary[]>([]);
const [busyId, setBusyId] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<WorkshopSubmissionSummary | null>(null);
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const load = useCallback(async () => {
@@ -61,10 +68,17 @@ export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) {
useEffect(() => {
const unsubscribe = client.subscribeToAuth(setUser);
void client.refreshAuth().then((nextUser) => {
setUser(nextUser);
if (nextUser?.isAdmin) void load();
});
void client
.refreshAuth()
.then(async (nextUser) => {
setUser(nextUser);
if (nextUser?.isAdmin) await load();
else setLoading(false);
})
.catch((cause) => {
setError(cause instanceof Error ? cause.message : "Unable to check Workshop moderator access");
setLoading(false);
});
return unsubscribe;
}, [client, load]);
@@ -109,7 +123,7 @@ export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) {
submission,
reports: reports.filter((report) => report.submission === submission.id),
}))
.sort(
.toSorted(
(left, right) =>
right.reports.length - left.reports.length ||
Date.parse(right.submission.updated) - Date.parse(left.submission.updated),
@@ -132,11 +146,6 @@ export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) {
<Button variant="outline" nativeButton={false} render={<a href="/workshop" aria-label="Back to Workshop" />}>
Back to Workshop
</Button>
{!user && (
<Button onClick={() => void signIn()}>
<IconBrandGithub size={15} /> Sign in with GitHub
</Button>
)}
{user?.isAdmin && (
<Button variant="outline" disabled={loading} onClick={() => void load()}>
<IconRefresh size={15} className={loading ? "animate-spin" : undefined} />
@@ -147,6 +156,33 @@ export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) {
</header>
<div className="py-6">
{loading && !user && (
<div className="space-y-3 rounded-xl border border-border p-6" aria-label="Checking moderator access">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-full max-w-md" />
<Skeleton className="h-8 w-40" />
</div>
)}
{!user && !loading && (
<Empty className="min-h-72 border border-border">
<EmptyHeader>
<EmptyMedia variant="icon">
<IconShield />
</EmptyMedia>
<EmptyTitle>Sign in to moderate the Workshop</EmptyTitle>
<EmptyDescription>
Moderator access is checked against your GitHub-linked Workshop account.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button onClick={() => void signIn()}>
<IconBrandGithub /> Sign in with GitHub
</Button>
</EmptyContent>
</Empty>
)}
{user && !user.isAdmin && (
<Alert variant="destructive" className="mb-5">
<IconShield />
@@ -186,7 +222,29 @@ export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) {
</span>
</div>
<div className="space-y-4">
{loading && submissions.length === 0 && (
<div className="space-y-4" aria-label="Loading Workshop moderation">
{Array.from({ length: 3 }, (_, index) => (
<Card key={index}>
<CardHeader className="sm:grid-cols-[1fr_auto]">
<div className="flex items-center gap-3">
<Skeleton className="size-10 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-4 w-52" />
<Skeleton className="h-3 w-28" />
</div>
</div>
<Skeleton className="h-5 w-20 rounded-full" />
</CardHeader>
<CardContent>
<Skeleton className="h-14 w-full" />
</CardContent>
</Card>
))}
</div>
)}
<div className="space-y-4" aria-live="polite">
{grouped.map(({ submission, reports: submissionReports }) => (
<Card
key={submission.id}
@@ -290,36 +348,41 @@ export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) {
</div>
{!loading && grouped.length === 0 && (
<div className="rounded-xl border border-dashed border-border py-16 text-center">
<IconShield size={28} className="mx-auto text-muted-foreground" />
<p className="mt-3 text-sm font-medium">No Workshop submissions</p>
<p className="mt-1 text-sm text-muted-foreground">Published submissions will appear here.</p>
</div>
<Empty className="border border-border py-16">
<EmptyHeader>
<EmptyMedia variant="icon">
<IconShield />
</EmptyMedia>
<EmptyTitle>No Workshop submissions</EmptyTitle>
<EmptyDescription>Published submissions will appear here.</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</section>
)}
</div>
<Dialog open={pendingDelete !== null} onOpenChange={(open) => !open && !busyId && setPendingDelete(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Delete {pendingDelete?.title}?</DialogTitle>
<DialogDescription>
<AlertDialog open={pendingDelete !== null} onOpenChange={(open) => !open && !busyId && setPendingDelete(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive">
<IconTrash />
</AlertDialogMedia>
<AlertDialogTitle>Delete {pendingDelete?.title}?</AlertDialogTitle>
<AlertDialogDescription>
This removes the Workshop listing and its reports, votes, screenshots, and comments. Installed local
copies remain. The submission author will be notified by email.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" disabled={busyId !== null} onClick={() => setPendingDelete(null)}>
Cancel
</Button>
<Button variant="destructive" disabled={busyId !== null} onClick={() => void remove()}>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={busyId !== null}>Cancel</AlertDialogCancel>
<AlertDialogAction variant="destructive" disabled={busyId !== null} onClick={() => void remove()}>
{busyId && <IconLoader2 size={14} className="animate-spin" />}
{busyId ? "Deleting" : "Delete submission"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</main>
);
}
+96 -117
View File
@@ -1,8 +1,8 @@
"use client";
import React, { useMemo, useState } from "react";
import {
IconAlertCircle,
IconArrowBigDown,
IconArrowBigUp,
IconBrandCss3,
IconBrandGithub,
IconChevronLeft,
@@ -35,6 +35,7 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -45,6 +46,7 @@ import { cn } from "@/lib/utils";
import { SubmitForm } from "./SubmitForm";
import { WorkshopAccountMenu } from "./WorkshopAccountMenu";
import { WorkshopVoteControl } from "./WorkshopVoteControl";
import { formatRelativeTime } from "./format";
import type { SortKey, TypeFilter } from "./useWorkshop";
import { useWorkshop } from "./useWorkshop";
@@ -74,11 +76,6 @@ const sortOptions: { value: SortKey; label: string }[] = [
{ value: "discussed", label: "Most discussed" },
];
const stopCardNavigation = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
};
const avatarFallback = (name: string) => name.trim().slice(0, 1).toUpperCase() || "?";
export const WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => {
@@ -165,13 +162,16 @@ export const WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => {
</div>
{workshop.submissions.length > 0 && (
<div className="mb-6 flex flex-col gap-3 rounded-lg border border-border bg-card p-3 sm:flex-row sm:items-center sm:justify-between">
<section
aria-label="Filter Workshop submissions"
className="mb-6 flex flex-col gap-3 rounded-lg border border-border bg-card p-3 sm:flex-row sm:items-center sm:justify-between"
>
<InputGroup className="order-first h-11 w-full sm:order-last sm:h-9 sm:w-64">
<InputGroupAddon>
<IconSearch size={16} />
</InputGroupAddon>
<InputGroupInput
placeholder="Search"
placeholder="Search Workshop"
aria-label="Search submissions"
value={search}
onChange={(event) => setSearch(event.target.value)}
@@ -221,7 +221,7 @@ export const WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => {
</Label>
</div>
</div>
</div>
</section>
)}
<p className="sr-only" aria-live="polite">
@@ -229,20 +229,20 @@ export const WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => {
</p>
{initialLoadFailed && (
<div className="flex min-h-80 flex-col items-center justify-center gap-4 rounded-xl border border-border bg-card px-6 py-12 text-center">
<div className="flex size-11 items-center justify-center rounded-xl bg-destructive/10 text-destructive">
<IconAlertCircle size={22} />
</div>
<div>
<h2 className="text-base font-semibold">Workshop listings could not be loaded</h2>
<p className="mt-1 max-w-md text-sm text-muted-foreground">
Check the Workshop service and your connection, then try again.
</p>
</div>
<Button variant="outline" onClick={() => void workshop.refresh()}>
<IconRefresh size={15} /> Try loading again
</Button>
</div>
<Empty className="min-h-80 border border-border bg-card">
<EmptyHeader>
<EmptyMedia className="flex size-11 items-center justify-center rounded-xl bg-destructive/10 text-destructive">
<IconAlertCircle size={22} />
</EmptyMedia>
<EmptyTitle>Workshop listings could not be loaded</EmptyTitle>
<EmptyDescription>Check the Workshop service and your connection, then try again.</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button variant="outline" onClick={() => void workshop.refresh()}>
<IconRefresh size={15} /> Try loading again
</Button>
</EmptyContent>
</Empty>
)}
{workshop.error && workshop.submissions.length > 0 && (
@@ -267,31 +267,36 @@ export const WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => {
)}
{!workshop.loading && !workshop.error && visible.length === 0 && (
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed border-border py-16">
<IconPackage size={28} stroke={1.5} className="text-muted-foreground" />
<div className="text-center">
<p className="text-sm font-medium">{empty.title}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{empty.hint}</p>
</div>
{workshop.submissions.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => {
setTypeFilter("all");
setSearch("");
setIncludeOutdated(true);
}}
>
Clear filters
</Button>
)}
{workshop.user && workshop.submissions.length === 0 && (
<Button size="sm" onClick={() => setShowSubmit(true)}>
<IconPlus size={14} /> Create submission
</Button>
)}
</div>
<Empty className="border border-border">
<EmptyHeader>
<EmptyMedia variant="icon">
<IconPackage />
</EmptyMedia>
<EmptyTitle>{empty.title}</EmptyTitle>
<EmptyDescription>{empty.hint}</EmptyDescription>
</EmptyHeader>
<EmptyContent>
{workshop.submissions.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => {
setTypeFilter("all");
setSearch("");
setIncludeOutdated(true);
}}
>
Clear filters
</Button>
)}
{workshop.submissions.length === 0 && (
<Button size="sm" onClick={() => (workshop.user ? setShowSubmit(true) : void workshop.login())}>
{workshop.user ? <IconPlus /> : <IconBrandGithub />}
{workshop.user ? "Create submission" : "Sign in to share"}
</Button>
)}
</EmptyContent>
</Empty>
)}
<div className="grid auto-rows-fr grid-cols-1 items-stretch gap-4 lg:grid-cols-2 xl:grid-cols-3">
@@ -337,24 +342,24 @@ const SubmissionCard = ({ submission, backend, userVote, onVote }: SubmissionCar
return (
<Card className="relative flex h-full min-w-0 w-full flex-col">
<a href={`/workshop/${submission.id}/`} className="block shrink-0">
{hasScreenshots ? (
<div className="relative">
<Badge
variant="secondary"
className="absolute left-2 top-2 z-10 gap-1.5 bg-background/80 px-2 backdrop-blur-sm"
>
<span className={cn("size-2 rounded-full", typeDotColors[submission.type])} />
{typeLabels[submission.type]}
</Badge>
<ScreenshotGallery urls={screenshotUrls} title={submission.title} />
</div>
) : (
{hasScreenshots ? (
<div className="relative shrink-0">
<Badge
variant="secondary"
className="absolute left-2 top-2 z-10 gap-1.5 bg-background/80 px-2 backdrop-blur-sm"
>
<span className={cn("size-2 rounded-full", typeDotColors[submission.type])} />
{typeLabels[submission.type]}
</Badge>
<ScreenshotGallery urls={screenshotUrls} title={submission.title} href={`/workshop/${submission.id}/`} />
</div>
) : (
<a href={`/workshop/${submission.id}/`} className="block shrink-0" aria-label={`View ${submission.title}`}>
<div className={cn(cardMediaClassName, "flex items-center justify-center", typeBgColors[submission.type])}>
<TypeIcon size={32} className="text-muted-foreground/20" />
</div>
)}
</a>
</a>
)}
<CardHeader className="flex flex-col gap-2 sm:grid">
<div className="flex items-center gap-2">
@@ -386,35 +391,12 @@ const SubmissionCard = ({ submission, backend, userVote, onVote }: SubmissionCar
</span>
</CardDescription>
<CardAction className="col-start-auto row-span-1 row-start-auto self-auto justify-self-start sm:col-start-2 sm:row-span-2 sm:row-start-1 sm:self-start sm:justify-self-end">
<div className="flex items-center gap-px rounded-md border border-border bg-muted/40 p-px">
<button
type="button"
onClick={() => void onVote(submission.id, 1)}
aria-label="Upvote"
aria-pressed={userVote === 1}
className={cn(
"flex size-10 items-center justify-center rounded-[5px] transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/50 sm:size-8",
userVote === 1 && "bg-primary/15 text-primary",
)}
>
<IconArrowBigUp size={14} />
</button>
<span aria-live="polite" className="min-w-5 text-center text-xs font-semibold tabular-nums text-foreground">
{score}
</span>
<button
type="button"
onClick={() => void onVote(submission.id, -1)}
aria-label="Downvote"
aria-pressed={userVote === -1}
className={cn(
"flex size-10 items-center justify-center rounded-[5px] transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/50 sm:size-8",
userVote === -1 && "bg-primary/15 text-primary",
)}
>
<IconArrowBigDown size={14} />
</button>
</div>
<WorkshopVoteControl
score={score}
userVote={userVote}
compact
onVote={(value) => void onVote(submission.id, value)}
/>
</CardAction>
</CardHeader>
@@ -499,59 +481,56 @@ export const WorkshopListingFallback = () => (
</div>
);
const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: string }) => {
const ScreenshotGallery = ({ urls, title, href }: { urls: string[]; title: string; href: string }) => {
const [idx, setIdx] = useState(0);
const dotClass = ["bg-white/40", "bg-white"];
return (
<div className="group/gallery relative">
<div className={cardMediaClassName}>
<a href={href} className={cn("block", cardMediaClassName)} aria-label={`View ${title}`}>
<img
className="h-full w-full object-cover"
src={urls[idx]}
alt={`${title} screenshot ${idx + 1}`}
loading="lazy"
/>
</div>
</a>
{urls.length > 1 && (
<>
<button
<Button
type="button"
className="absolute left-2 top-1/2 flex size-10 -translate-y-1/2 items-center justify-center rounded-lg bg-background/85 opacity-80 shadow-sm transition-opacity hover:opacity-100 sm:size-8"
onClick={(event) => {
stopCardNavigation(event);
setIdx((i) => (i - 1 + urls.length) % urls.length);
}}
variant="secondary"
size="icon"
className="absolute left-2 top-1/2 -translate-y-1/2 bg-background/90 opacity-85 shadow-sm hover:opacity-100 sm:size-8"
onClick={() => setIdx((i) => (i - 1 + urls.length) % urls.length)}
aria-label="Previous screenshot"
>
<IconChevronLeft size={14} />
</button>
<button
</Button>
<Button
type="button"
className="absolute right-2 top-1/2 flex size-10 -translate-y-1/2 items-center justify-center rounded-lg bg-background/85 opacity-80 shadow-sm transition-opacity hover:opacity-100 sm:size-8"
onClick={(event) => {
stopCardNavigation(event);
setIdx((i) => (i + 1) % urls.length);
}}
variant="secondary"
size="icon"
className="absolute right-2 top-1/2 -translate-y-1/2 bg-background/90 opacity-85 shadow-sm hover:opacity-100 sm:size-8"
onClick={() => setIdx((i) => (i + 1) % urls.length)}
aria-label="Next screenshot"
>
<IconChevronRight size={14} />
</button>
</Button>
<div className="absolute bottom-2 left-1/2 flex -translate-x-1/2 rounded-full bg-black/60 px-1 py-0.5">
{urls.map((_, i) => (
<button
<Button
type="button"
variant="ghost"
size="icon"
key={i}
onClick={(event) => {
stopCardNavigation(event);
setIdx(i);
}}
onClick={() => setIdx(i)}
aria-label={`Screenshot ${i + 1}`}
aria-current={i === idx ? "true" : undefined}
className="flex size-8 items-center justify-center rounded-full focus-visible:ring-2 focus-visible:ring-white"
className="size-8 rounded-full hover:bg-white/10 focus-visible:ring-white"
>
<span className={cn("size-1.5 rounded-full transition-colors", dotClass[Number(i === idx)])} />
</button>
</Button>
))}
</div>
</>
@@ -1,7 +1,10 @@
"use client";
import React from "react";
import { IconAlertTriangle } from "@tabler/icons-react";
import { Button } from "@/components/ui/button";
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
interface State {
failed: boolean;
@@ -18,18 +21,18 @@ export class WorkshopErrorBoundary extends React.Component<React.PropsWithChildr
if (!this.state.failed) return this.props.children;
return (
<div className="mx-auto flex min-h-[50vh] max-w-xl flex-col items-center justify-center gap-4 px-4 text-center">
<div className="flex size-11 items-center justify-center rounded-xl bg-destructive/10 text-destructive">
<IconAlertTriangle size={22} />
</div>
<div>
<h1 className="text-xl font-semibold">Workshop could not be displayed</h1>
<p className="mt-1 text-sm text-muted-foreground">
Reload the page to try again. Your Homarr installation is not affected.
</p>
</div>
<Button onClick={() => window.location.reload()}>Reload Workshop</Button>
</div>
<Empty className="mx-auto min-h-[50vh] max-w-xl px-4">
<EmptyHeader>
<EmptyMedia className="flex size-11 items-center justify-center rounded-xl bg-destructive/10 text-destructive">
<IconAlertTriangle size={22} />
</EmptyMedia>
<EmptyTitle className="text-xl">Workshop could not be displayed</EmptyTitle>
<EmptyDescription>Reload the page to try again. Your Homarr installation is not affected.</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button onClick={() => window.location.reload()}>Reload Workshop</Button>
</EmptyContent>
</Empty>
);
}
}
@@ -0,0 +1,38 @@
"use client";
import { usePathname } from "next/navigation";
import { IconBook, IconPackage } from "@tabler/icons-react";
import { Button } from "@/components/ui/button";
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
import MarketplaceDetailPage from "./DetailPage";
export function WorkshopNotFoundRouter({ configuredWorkshopUrl }: { configuredWorkshopUrl: string }) {
const pathname = usePathname() ?? "";
const isWorkshopDetail = /^\/workshop\/[^/]+\/?$/.test(pathname) && !/^\/workshop\/admin\/?$/.test(pathname);
if (isWorkshopDetail) return <MarketplaceDetailPage configuredWorkshopUrl={configuredWorkshopUrl} />;
return (
<main>
<Empty className="mx-auto min-h-[60vh] max-w-xl px-5 py-16">
<EmptyHeader>
<EmptyMedia variant="icon">
<IconPackage />
</EmptyMedia>
<EmptyTitle className="text-xl">Page not found</EmptyTitle>
<EmptyDescription>The page may have moved, or the address may be incomplete.</EmptyDescription>
</EmptyHeader>
<EmptyContent className="flex-row justify-center">
<Button nativeButton={false} render={<a href="/docs" aria-label="Browse documentation" />}>
<IconBook /> Browse documentation
</Button>
<Button variant="outline" nativeButton={false} render={<a href="/workshop" aria-label="Open Workshop" />}>
<IconPackage /> Open Workshop
</Button>
</EmptyContent>
</Empty>
</main>
);
}
@@ -0,0 +1,18 @@
"use client";
import { getRuntimeWorkshopApiUrl } from "@/lib/runtime-config";
import { WorkshopAdmin } from "./WorkshopAdmin";
import { WorkshopApp } from "./WorkshopApp";
interface WorkshopRouteProps {
configuredWorkshopUrl: string;
}
export function WorkshopRoute({ configuredWorkshopUrl }: WorkshopRouteProps) {
return <WorkshopApp workshopUrl={getRuntimeWorkshopApiUrl(configuredWorkshopUrl)} />;
}
export function WorkshopAdminRoute({ configuredWorkshopUrl }: WorkshopRouteProps) {
return <WorkshopAdmin workshopUrl={getRuntimeWorkshopApiUrl(configuredWorkshopUrl)} />;
}
@@ -0,0 +1,79 @@
"use client";
import { IconArrowBigDown, IconArrowBigUp } from "@tabler/icons-react";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
interface WorkshopVoteControlProps {
score: number;
userVote?: 1 | -1;
onVote: (value: 1 | -1) => void;
compact?: boolean;
className?: string;
}
export function WorkshopVoteControl({ score, userVote, onVote, compact = false, className }: WorkshopVoteControlProps) {
const upvoteLabel = userVote === 1 ? "Remove upvote" : "Upvote";
const downvoteLabel = userVote === -1 ? "Remove downvote" : "Downvote";
return (
<TooltipProvider delay={350}>
<div
className={cn(
"flex items-center gap-px rounded-lg border border-border bg-muted/40",
compact ? "p-px" : "p-1",
className,
)}
aria-label="Submission score"
>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size={compact ? "icon-sm" : "icon"}
aria-label={upvoteLabel}
aria-pressed={userVote === 1}
onClick={() => onVote(1)}
className={cn(userVote === 1 && "bg-primary/15 text-primary hover:bg-primary/20 hover:text-primary")}
/>
}
>
<IconArrowBigUp />
</TooltipTrigger>
<TooltipContent>{upvoteLabel}</TooltipContent>
</Tooltip>
<span
aria-live="polite"
className={cn(
"text-center font-semibold tabular-nums text-foreground",
compact ? "min-w-5 text-xs" : "min-w-7 text-sm",
)}
>
{score}
</span>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size={compact ? "icon-sm" : "icon"}
aria-label={downvoteLabel}
aria-pressed={userVote === -1}
onClick={() => onVote(-1)}
className={cn(userVote === -1 && "bg-primary/15 text-primary hover:bg-primary/20 hover:text-primary")}
/>
}
>
<IconArrowBigDown />
</TooltipTrigger>
<TooltipContent>{downvoteLabel}</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
);
}
@@ -1,3 +1,5 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import dayjs from "dayjs";
-593
View File
@@ -1,593 +0,0 @@
@import "https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Work+Sans:wght@400;500;600;700&display=swap";
@import "tailwindcss/theme" layer(theme);
@import "tailwindcss/utilities" layer(utilities);
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
/* tweakcn theme cmrw8cy5g000104jv0b88cnsb on shadcn base-nova */
@theme inline {
--spacing-128: 32rem;
--breakpoint-3xl: 1697px;
--font-heading: var(--font-sans);
--font-sans: "Work Sans", sans-serif;
--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);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
--font-mono: "JetBrains Mono", monospace;
--font-serif: Georgia, serif;
--radius: 0.75rem;
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
--tracking-normal: var(--tracking-normal);
--shadow-2xl: var(--shadow-2xl);
--shadow-xl: var(--shadow-xl);
--shadow-lg: var(--shadow-lg);
--shadow-md: var(--shadow-md);
--shadow: var(--shadow);
--shadow-sm: var(--shadow-sm);
--shadow-xs: var(--shadow-xs);
--shadow-2xs: var(--shadow-2xs);
--spacing: var(--spacing);
--letter-spacing: var(--letter-spacing);
--shadow-offset-y: var(--shadow-offset-y);
--shadow-offset-x: var(--shadow-offset-x);
--shadow-spread: var(--shadow-spread);
--shadow-blur: var(--shadow-blur);
--shadow-opacity: var(--shadow-opacity);
--color-shadow-color: var(--shadow-color);
--color-destructive-foreground: var(--destructive-foreground);
}
:root {
--ifm-color-primary: #c62828;
--ifm-color-primary-dark: #b82525;
--ifm-color-primary-darker: #ae2323;
--ifm-color-primary-darkest: #861919;
--ifm-color-primary-light: #d12f2f;
--ifm-color-primary-lighter: #d63333;
--ifm-color-primary-lightest: #d93636;
--ifm-code-font-size: 95%;
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1);
--homarr-secondary-bg: #e8e8e8;
--aa-primary-color-rgb: 198, 40, 40 !important;
--aa-muted-color-rgb: 198, 40, 40 !important;
--aa-background-color-rgb: 200, 200, 200 !important;
--ifm-font-family-base: var(--font-sans);
--ifm-color-content-inverse: var(--ifm-color-emphasis-100);
/* Bridge Docusaurus to shadcn theme */
--ifm-background-color: var(--background);
--ifm-font-color-base: var(--foreground);
--ifm-navbar-background-color: var(--card);
--ifm-footer-background-color: var(--card);
--radius: 0.75rem;
--background: oklch(1 0 0);
--foreground: oklch(0.2178 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.2178 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.2178 0 0);
--primary: #c62828;
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.2178 0 0);
--secondary-foreground: oklch(1 0 0);
--muted: oklch(0.9674 0.0013 286.3752);
--muted-foreground: oklch(0.5517 0.0138 285.9385);
--accent: oklch(0.955 0.025 25.6509);
--accent-foreground: oklch(0.48 0.18 25.6509);
--destructive: oklch(0.6368 0.2078 25.3313);
--border: oklch(0.9197 0.004 286.3202);
--input: oklch(0.9197 0.004 286.3202);
--ring: #c62828;
--chart-1: oklch(0.6646 0.2222 25.6509);
--chart-2: oklch(0.2178 0 0);
--chart-3: oklch(0.6231 0.188 259.8145);
--chart-4: oklch(0.6959 0.1491 162.4796);
--chart-5: oklch(0.7686 0.1647 70.0804);
--sidebar: oklch(0.9791 0 0);
--sidebar-foreground: oklch(0.2178 0 0);
--sidebar-primary: #c62828;
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.9551 0 0);
--sidebar-accent-foreground: oklch(0.6646 0.2222 25.6509);
--sidebar-border: oklch(0.9276 0.0058 264.5313);
--sidebar-ring: oklch(0.56 0.205 25.6509);
--destructive-foreground: oklch(0.9851 0 0);
--font-sans: "Work Sans", sans-serif;
--font-serif: Georgia, serif;
--font-mono: "JetBrains Mono", monospace;
--shadow-color: #000000;
--shadow-opacity: 0.1;
--shadow-blur: 10px;
--shadow-spread: -2px;
--shadow-offset-x: 0px;
--shadow-offset-y: 4px;
--letter-spacing: -0.02em;
--spacing: 0.25rem;
--shadow-2xs: 0px 4px 10px -2px hsl(0 0% 0% / 0.05);
--shadow-xs: 0px 4px 10px -2px hsl(0 0% 0% / 0.05);
--shadow-sm: 0px 4px 10px -2px hsl(0 0% 0% / 0.1), 0px 1px 2px -3px hsl(0 0% 0% / 0.1);
--shadow: 0px 4px 10px -2px hsl(0 0% 0% / 0.1), 0px 1px 2px -3px hsl(0 0% 0% / 0.1);
--shadow-md: 0px 4px 10px -2px hsl(0 0% 0% / 0.1), 0px 2px 4px -3px hsl(0 0% 0% / 0.1);
--shadow-lg: 0px 4px 10px -2px hsl(0 0% 0% / 0.1), 0px 4px 6px -3px hsl(0 0% 0% / 0.1);
--shadow-xl: 0px 4px 10px -2px hsl(0 0% 0% / 0.1), 0px 8px 10px -3px hsl(0 0% 0% / 0.1);
--shadow-2xl: 0px 4px 10px -2px hsl(0 0% 0% / 0.25);
--tracking-normal: -0.02em;
}
body {
background-color: var(--background);
color: var(--foreground);
font-family: var(--ifm-font-family-base);
}
[data-theme="dark"] {
--ifm-color-primary: #fa5252;
--ifm-color-primary-dark: #f93232;
--ifm-color-primary-darker: #f92222;
--ifm-color-primary-darkest: #d20606;
--ifm-color-primary-light: #fb7272;
--ifm-color-primary-lighter: #fb8282;
--ifm-color-primary-lightest: #fdc3c3;
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
--homarr-secondary-bg: oklch(0.245 0.006 223.9);
--ifm-color-content-inverse: var(--ifm-color-emphasis-100);
--primary: #fa5252;
--ring: #fa5252;
--sidebar-primary: #fa5252;
--aa-primary-color-rgb: 250, 82, 82 !important;
--aa-muted-color-rgb: 250, 82, 82 !important;
--aa-background-color-rgb: 24, 24, 24 !important;
}
/* Reset form elements that don't inherit by default (no preflight in Docusaurus).
Must live inside @layer theme so layer(utilities) wins in the cascade. */
@layer theme {
:where(
main.marketplace,
[data-slot="dialog-portal"],
[data-slot="alert-dialog-content"],
[data-slot="dropdown-menu-content"],
[data-slot="select-content"],
[data-slot="popover-content"]
)
:where(button, input, select, textarea) {
font-family: inherit;
font-size: inherit;
line-height: inherit;
color: inherit;
background: transparent;
border: none;
padding: 0;
margin: 0;
}
:where(
main.marketplace,
[data-slot="dialog-portal"],
[data-slot="alert-dialog-content"],
[data-slot="dropdown-menu-content"],
[data-slot="select-content"],
[data-slot="popover-content"]
)
:where(button) {
cursor: pointer;
}
:where(
[data-slot="dialog-portal"],
[data-slot="alert-dialog-content"],
[data-slot="dropdown-menu-content"],
[data-slot="select-content"],
[data-slot="popover-content"]
) {
font-family: var(--ifm-font-family-base);
color: var(--foreground);
}
}
.aa-DetachedOverlay {
backdrop-filter: blur(10px) brightness(0.5);
}
.aa-DetachedContainer {
box-shadow: none !important;
}
.navbar__logo {
margin-right: 1rem;
}
.swiper-slide {
width: initial !important;
height: auto !important;
padding-bottom: 40px;
}
.swiper-pagination {
position: absolute;
bottom: 0;
}
.swiper-pagination-bullet-active {
background-color: var(--ifm-color-primary) !important;
}
.footer {
background-color: var(--homarr-secondary-bg) !important;
}
.theme-doc-markdown,
.blog-post-page article .markdown,
.footer {
--ifm-link-decoration: underline;
}
.theme-doc-markdown a,
.blog-post-page article .markdown a,
.footer__link-item {
text-underline-offset: 0.16em;
}
[data-theme="dark"] .theme-doc-markdown a code,
[data-theme="dark"].blog-post-page article .markdown a code {
color: var(--ifm-color-primary-light);
}
.footer__bottom {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 1.25rem;
width: min(100%, 38rem);
margin: 2.5rem auto 0;
padding-top: 2rem;
border-top: 1px solid color-mix(in srgb, var(--foreground) 14%, transparent);
text-align: left !important;
}
.footer__bottom .margin-bottom--sm {
margin: 0 !important;
}
.footer__logo {
display: block;
width: 5.375rem;
height: auto;
transition: transform 180ms cubic-bezier(0.22, 1, 0.36, 1);
}
.footer__bottom a:hover .footer__logo {
transform: translateY(-2px);
}
.footer__copyright {
text-align: left;
}
.footer__brand-copy {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.2rem;
}
.footer__brand-name {
color: var(--foreground);
font-size: 1.125rem;
line-height: 1.25;
letter-spacing: -0.025em;
}
.footer__brand-tagline {
color: color-mix(in srgb, var(--foreground) 78%, transparent);
font-size: 0.875rem;
line-height: 1.45;
}
.footer__copyright span.copyright_text {
color: color-mix(in srgb, var(--foreground) 72%, transparent);
font-size: 0.75rem;
}
@media (max-width: 640px) {
.footer__bottom {
grid-template-columns: 1fr;
justify-items: center;
gap: 0.875rem;
margin-top: 2rem;
padding-top: 1.5rem;
text-align: center !important;
}
.footer__copyright {
text-align: center;
}
.footer__brand-copy {
align-items: center;
}
}
@media (prefers-reduced-motion: reduce) {
.footer__logo {
transition: none;
}
.footer__bottom a:hover .footer__logo {
transform: none;
}
}
.medium-zoom-overlay {
background-color: var(--ifm-color-primary-lighter) !important;
}
.medium-zoom-image {
border-radius: 0.5rem;
}
.menu__list {
transition: all 0.5s ease-in-out !important;
}
.announcementBar_node_modules-\@docusaurus-theme-classic-lib-theme-AnnouncementBar-styles-module {
background-color: transparent !important;
background-image: radial-gradient(circle, rgb(253, 126, 20) 0%, rgb(250, 82, 82) 100%) !important;
font-weight: 600;
}
.medium-zoom-overlay {
background-color: transparent !important;
backdrop-filter: blur(10px) brightness(0.7);
}
.navbar__inner .navbar__items a.navbar__item,
.navbar__inner .navbar__items div.navbar__item.dropdown {
white-space: nowrap;
}
/* Keep the primary docs destinations readable before Docusaurus switches to its mobile menu. */
@media (min-width: 997px) and (max-width: 1399px) {
.navbar__inner > .navbar__items > .navbar-item--collapse-first,
.navbar__inner > .navbar__items > .navbar-item--secondary {
display: none;
}
}
@media (min-width: 997px) and (max-width: 1179px) {
.navbar__inner .navbar__title {
display: none;
}
}
/*.navbar__items.navbar__items--right .navbar__item.navbar__link:first-of-type {
background-color: var(--ifm-color-primary-light) !important;
border-radius: 30px;
}
.navbar__items.navbar__items--right .navbar__item.navbar__link:first-of-type:hover {
background-color: var(--ifm-color-primary) !important;
color: var(--ifm-navbar-link-color) !important;
}*/
html[data-theme="dark"] div#__docusaurus div[role="banner"] {
background-color: #1b1b1d;
color: #c1c1c1;
}
html[data-theme="dark"] div#__docusaurus div[role="banner"] button svg {
color: white;
}
.navbar__inner a.navbar__brand {
font-size: 22px;
color: var(--ifm-color-primary);
}
:is(p, h3, h4, h5, h6):has(+ :is(ul, ol)) {
margin-bottom: 4px !important;
}
.table-of-contents__left-border {
border-left: none !important;
}
.thin-scrollbar.theme-doc-toc-desktop {
background-color: var(--ifm-navbar-background-color);
border-radius: 20px;
}
.table-of-contents__link--active {
font-weight: bold !important;
}
.pagination-nav__link {
background-color: var(--ifm-navbar-background-color);
border: 4px transparent solid;
}
.row.theme-doc-footer-tags-row ul li a {
border: none;
background-color: var(--ifm-navbar-background-color);
border-radius: 20px;
padding: 6px 12px;
}
.hero-cards {
transform: rotate(20deg) scale(0.8) skew(21deg);
-webkit-transform: rotate(20deg) scale(0.8) skew(21deg);
-moz-transform: rotate(20deg) scale(0.8) skew(21deg);
}
.animated-width {
transition: width 0.3s ease-in-out;
}
.app-card {
cursor: not-allowed;
}
.app-card img {
transition: transform 0.1s ease-in-out;
}
.app-card:hover img {
transform: scale(1.08) !important;
}
.hover-animation {
animation: hover-animation 5s ease-in-out forwards;
animation-iteration-count: infinite;
}
@keyframes hover-animation {
0% {
transform: translateY(0);
}
50% {
transform: translateY(10px);
}
100% {
transform: translateY(0);
}
}
.submit-step-enter {
animation: submit-step-enter 0.2s ease-in-out forwards;
}
.submit-step-from-right {
--submit-step-x: 80px;
}
.submit-step-from-left {
--submit-step-x: -80px;
}
@media (prefers-reduced-motion: reduce) {
.hover-animation {
animation: none;
}
.submit-step-enter {
animation: none;
}
}
@keyframes submit-step-enter {
from {
transform: translateX(var(--submit-step-x, 80px));
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
[data-theme="dark"] {
--background: #1b1b1d;
--foreground: oklch(0.9791 0 0);
--card: oklch(0.245 0.006 223.9);
--card-foreground: oklch(0.9791 0 0);
--popover: oklch(0.245 0.006 223.9);
--popover-foreground: oklch(0.9791 0 0);
--primary: #fa5252;
--primary-foreground: oklch(0.15 0.025 25.6509);
--secondary: oklch(0.2739 0.0055 286.0326);
--secondary-foreground: oklch(0.9851 0 0);
--muted: oklch(0.2739 0.0055 286.0326);
--muted-foreground: oklch(0.7118 0.0129 286.0665);
--accent: oklch(0.31 0.055 25.6509);
--accent-foreground: oklch(0.86 0.1 25.6509);
--destructive: oklch(0.3958 0.1331 25.723);
--destructive-foreground: oklch(0.9705 0.0129 17.3797);
--border: oklch(0.2739 0.0055 286.0326);
--input: oklch(0.2739 0.0055 286.0326);
--ring: #fa5252;
--chart-1: oklch(0.6646 0.2222 25.6509);
--chart-2: oklch(0.4419 0.0146 285.7864);
--chart-3: oklch(0.7137 0.1434 254.624);
--chart-4: oklch(0.7729 0.1535 163.2231);
--chart-5: oklch(0.8369 0.1644 84.4286);
--radius: 0.75rem;
--sidebar: oklch(0.2178 0 0);
--sidebar-foreground: oklch(0.9791 0 0);
--sidebar-primary: #fa5252;
--sidebar-primary-foreground: oklch(0.15 0.025 25.6509);
--sidebar-accent: oklch(0.2739 0.0055 286.0326);
--sidebar-accent-foreground: oklch(0.6646 0.2222 25.6509);
--sidebar-border: oklch(0.2739 0.0055 286.0326);
--sidebar-ring: oklch(0.72 0.16 25.6509);
--font-sans: "Work Sans", sans-serif;
--font-serif: Georgia, serif;
--font-mono: "JetBrains Mono", monospace;
--shadow-color: #000000;
--shadow-opacity: 0.5;
--shadow-blur: 20px;
--shadow-spread: -5px;
--shadow-offset-x: 0px;
--shadow-offset-y: 10px;
--letter-spacing: -0.02em;
--spacing: 0.25rem;
--shadow-2xs: 0px 10px 20px -5px hsl(0 0% 0% / 0.25);
--shadow-xs: 0px 10px 20px -5px hsl(0 0% 0% / 0.25);
--shadow-sm: 0px 10px 20px -5px hsl(0 0% 0% / 0.5), 0px 1px 2px -6px hsl(0 0% 0% / 0.5);
--shadow: 0px 10px 20px -5px hsl(0 0% 0% / 0.5), 0px 1px 2px -6px hsl(0 0% 0% / 0.5);
--shadow-md: 0px 10px 20px -5px hsl(0 0% 0% / 0.5), 0px 2px 4px -6px hsl(0 0% 0% / 0.5);
--shadow-lg: 0px 10px 20px -5px hsl(0 0% 0% / 0.5), 0px 4px 6px -6px hsl(0 0% 0% / 0.5);
--shadow-xl: 0px 10px 20px -5px hsl(0 0% 0% / 0.5), 0px 8px 10px -6px hsl(0 0% 0% / 0.5);
--shadow-2xl: 0px 10px 20px -5px hsl(0 0% 0% / 1.25);
}
@layer base {
body {
letter-spacing: var(--tracking-normal);
}
}
-31
View File
@@ -2,34 +2,3 @@ declare module "*.mp4" {
const src: string;
export default src;
}
declare module "*.gif" {
const src: string;
export default src;
}
declare module "*.png" {
const src: string;
export default src;
}
declare module "@theme/Tabs" {
import type { ComponentProps, ReactNode } from "react";
interface Props extends ComponentProps<"div"> {
children: ReactNode;
className?: string;
}
export default function Tabs(props: Props): JSX.Element;
}
declare module "@theme/TabItem" {
import type { ReactNode } from "react";
interface Props {
children: ReactNode;
value: string;
label?: ReactNode;
className?: string;
key?: string;
}
export default function TabItem(props: Props): JSX.Element;
}
+14
View File
@@ -0,0 +1,14 @@
"use client";
import { useTheme } from "next-themes";
export function useColorMode() {
const { resolvedTheme, setTheme } = useTheme();
const colorMode = resolvedTheme === "dark" ? "dark" : "light";
return {
colorMode,
isDarkTheme: colorMode === "dark",
setColorMode: setTheme,
} as const;
}
+6
View File
@@ -0,0 +1,6 @@
const legacyDocsPath = /^(?:\.\.\/)+(integrations|management|widgets)\/(.+)$/;
export const getDocsHref = (path: string) => {
const match = legacyDocsPath.exec(path);
return match ? `/docs/${match[1]}/${match[2]}` : path;
};
+94
View File
@@ -0,0 +1,94 @@
import { IconBrandDiscord, IconBrandGithub, IconBrandReddit, IconBuildingStore, IconHeart } from "@tabler/icons-react";
import Image from "next/image";
import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
export function baseOptions(): BaseLayoutProps {
return {
nav: {
title: (
<span className="homarr-brand">
<Image className="homarr-brand-logo" src="/img/logo.svg" alt="" width={30} height={20} aria-hidden />
<span className="homarr-brand-name">Homarr</span>
</span>
),
},
links: [
{
text: "Documentation",
url: "/docs",
active: "nested-url",
},
{
text: "Workshop",
url: "/workshop",
active: "nested-url",
},
{
text: "API",
url: "/api-reference",
active: "url",
},
{
text: "Blog",
url: "/blog",
active: "nested-url",
secondary: true,
},
{
text: "Community",
url: "/docs/community",
active: "nested-url",
secondary: true,
},
{
text: "About us",
url: "/about-us",
active: "url",
secondary: true,
},
{
type: "icon",
text: "Donate",
label: "Donate to Homarr",
icon: <IconHeart />,
url: "https://opencollective.com/homarr",
external: true,
on: "menu",
},
{
type: "icon",
text: "Workshop",
label: "Open Community Workshop",
icon: <IconBuildingStore />,
url: "/workshop",
on: "menu",
},
{
type: "icon",
text: "Discord",
label: "Join Homarr on Discord",
icon: <IconBrandDiscord />,
url: "https://discord.com/invite/aCsmEV5RgA",
external: true,
on: "menu",
},
{
type: "icon",
text: "Reddit",
label: "Visit Homarr on Reddit",
icon: <IconBrandReddit />,
url: "https://www.reddit.com/r/homarr/",
external: true,
on: "menu",
},
{
type: "icon",
text: "GitHub",
label: "Open Homarr on GitHub",
icon: <IconBrandGithub />,
url: "https://github.com/homarr-labs/homarr",
external: true,
},
],
};
}
+17
View File
@@ -0,0 +1,17 @@
import { loader } from "fumadocs-core/source";
import { createOpenAPI } from "fumadocs-openapi/server";
export const openapi = createOpenAPI({
input: {
homarr: "./public/api/open-api-schema.json",
},
});
export const apiSource = loader({
baseUrl: "/api-reference",
source: await openapi.staticSource({
per: "operation",
groupBy: "tag",
meta: true,
}),
});
+125
View File
@@ -0,0 +1,125 @@
import { loader } from "fumadocs-core/source";
import { metaSchema, pageSchema } from "fumadocs-core/source/schema";
import { defineCollections, defineDocs } from "fumadocs-mdx/macro";
import { z } from "zod";
const titleFromSource = (source: string, path: string) => {
const heading = /^#\s+(.+)$/m
.exec(source)?.[1]
?.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
.replace(/[*_`]/g, "")
.trim();
if (heading) return heading;
const name =
path
.split("/")
.at(-1)
?.replace(/\.(md|mdx)$/, "")
.replace(/^index$/, "Overview") ?? "Overview";
return name.replace(/[-_]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
};
const docs = defineDocs({
dir: "docs",
docs: {
schema: ({ source, path }) =>
pageSchema
.extend({
title: z.string().optional(),
hide_title: z.boolean().optional(),
sidebar_label: z.string().optional(),
sidebar_position: z.number().optional(),
tags: z.array(z.string()).optional(),
})
.transform((data) => ({ ...data, title: data.title ?? titleFromSource(source, path) })),
postprocess: {
extractLinkReferences: true,
includeProcessedMarkdown: true,
},
},
meta: {
schema: metaSchema,
},
});
export const posts = defineCollections({
type: "doc",
dir: "blog",
files: ["**/index.mdx"],
schema: pageSchema.extend({
date: z.string().date(),
authors: z.array(z.string()),
tags: z.array(z.string()).optional(),
slug: z.string().optional(),
}),
postprocess: {
extractLinkReferences: true,
includeProcessedMarkdown: true,
},
});
export const docsRoute = "/docs";
export const docsContentRoute = "/llms.mdx/docs";
export const source = loader({
baseUrl: docsRoute,
source: docs.toFumadocsSource(),
});
export function getPageMarkdownUrl(page: (typeof source)["$inferPage"]) {
const segments = [...page.slugs, "content.md"];
return {
segments,
url: `/${[...docsContentRoute.split("/"), ...segments].filter(Boolean).join("/")}`,
};
}
export async function getLLMText(page: (typeof source)["$inferPage"]) {
const processed = await page.data.getText("processed");
const body = processed.replace(/^\s*#\s+[^\n]+\n+/, "");
return `# ${page.data.title} (${page.url})\n\n${body}`;
}
export type BlogPost = (typeof posts.entries)[number];
export function getPostSegments(post: BlogPost) {
if (post.slug) return [post.slug];
const [year, datedFolder = ""] = post.info.path.split("/");
const match = /^(\d{2})-(\d{2})-(.+)$/.exec(datedFolder);
return match ? [year, match[1], match[2], match[3]] : [year, datedFolder].filter(Boolean);
}
export function getPostUrl(post: BlogPost) {
return `/blog/${getPostSegments(post).join("/")}`;
}
export function getPostBySegments(segments: string[]) {
return posts.entries.find((post) => getPostSegments(post).join("/") === segments.join("/"));
}
export function getPostMarkdownUrl(post: BlogPost) {
return `/llms.mdx/blog/${[...getPostSegments(post), "content.md"].join("/")}`;
}
export async function getPostLLMText(post: BlogPost) {
const processed = await post.getText("processed");
return `# ${post.title} (${getPostUrl(post)})\n\n${processed}`;
}
export async function getPostExcerpt(post: BlogPost) {
const raw = await post.getText("raw");
return raw
.replace(/^---[\s\S]*?---\s*/, "")
.split("{/* Historical excerpt boundary. */}", 1)[0]
.replace(/^import\s.+$/gm, "")
.replace(/^#\s+.+$/m, "")
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
.replace(/[*_`>#]/g, "")
.replace(/\s+/g, " ")
.trim();
}
-32
View File
@@ -1,32 +0,0 @@
import Layout from "@theme/Layout";
import React from "react";
import { CodeContributorList } from "@site/src/components/pages/about/code-contributors/code-contributor-list";
import { TranslationContributorList } from "@site/src/components/pages/about/translations-contributors/translation-constributor-list";
export default function AboutUs() {
return (
<Layout
title="About Homarr"
description={
"Homarr is a community driven open source project that is being maintained by volunteers. Homarr has been a growing project since 2021."
}
>
<main className="mx-auto w-full md:w-2/3 ps-10 pr-10 mb-20 mt-10">
<h1 className="text-5xl font-extrabold">About us</h1>
<p className="text-lg text-gray-500">
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>
<h2 className={"mt-10"}>Code contributions</h2>
<CodeContributorList />
<h2 className={"mt-10"}>Translation contributions</h2>
<TranslationContributorList />
</main>
</Layout>
);
}
-29
View File
@@ -1,29 +0,0 @@
import React from "react";
import Layout from "@theme/Layout";
import BrowserOnly from "@docusaurus/BrowserOnly";
export default function ApiReferencePage() {
return (
<Layout title="API Reference" description="Homarr OpenAPI Reference">
<BrowserOnly fallback={<div style={{ height: "100vh" }} />}>
{() => {
const { ThemedApiReference } = require("@site/src/components/themed-api-reference");
return (
<ThemedApiReference
configuration={{
url: "/api/open-api-schema.json",
theme: "kepler",
hideModels: false,
hideDownloadButton: false,
mcp: { disabled: true },
customCss: `
.scalar-sidebar-footer a[href="https://www.scalar.com"] { display: none !important; }
`,
}}
/>
);
}}
</BrowserOnly>
</Layout>
);
}
+144 -46
View File
@@ -1,63 +1,107 @@
/**
* CSS files with the .module.css suffix will be treated as CSS modules
* and scoped locally.
*/
section {
margin-bottom: 2rem;
}
.heroWave {
width: 100%;
height: 125px;
}
.hero h2 {
font-size: 2rem;
}
.hero p {
font-weight: 500;
}
.heroImage {
display: block;
margin-left: auto;
border-radius: 6px;
max-width: min(100%, 500px);
}
.heroBanner {
padding: clamp(3rem, 6vw, 6rem) clamp(1.25rem, 4vw, 4rem);
position: relative;
overflow: hidden;
padding: clamp(3rem, 6vw, 6rem) clamp(1.25rem, 4vw, 4rem);
background: var(--ifm-color-primary);
color: var(--primary-foreground);
}
.heroContent {
display: grid;
grid-template-columns: minmax(25rem, 1fr) minmax(44rem, 48rem);
grid-template-columns: minmax(24rem, 1fr) minmax(44rem, 48rem);
align-items: center;
gap: clamp(1rem, 3vw, 3rem);
max-width: 108rem;
width: 100%;
max-width: 96rem;
margin-inline: auto;
}
.heroCopy {
position: relative;
z-index: 1;
max-width: 52rem;
}
.heroTitle {
margin: 0;
color: inherit;
font-size: clamp(3rem, 4.45vw, 4.5rem);
font-weight: 800;
line-height: 0.98;
letter-spacing: -0.04em;
text-wrap: balance;
}
.heroDescription {
max-width: 46rem;
margin: 1.75rem 0 0;
color: inherit;
font-size: clamp(1rem, 1.5vw, 1.35rem);
font-weight: 500;
line-height: 1.55;
text-wrap: pretty;
}
.heroActions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1.75rem;
}
.heroButton {
display: inline-flex;
min-height: 2.75rem;
align-items: center;
justify-content: center;
gap: 0.65rem;
padding: 0.7rem 1rem;
border: 1px solid color-mix(in srgb, #111827 12%, transparent);
border-radius: 999px;
background: #fff;
color: #27272a;
font-size: 0.95rem;
font-weight: 700;
line-height: 1;
text-decoration: none;
box-shadow: 0 2px 8px rgb(49 46 129 / 10%);
transition:
background-color 150ms ease,
box-shadow 150ms ease;
}
.heroButton:hover {
background: #f4f4f5;
color: #18181b;
box-shadow: 0 4px 14px rgb(49 46 129 / 18%);
}
.partnerButton,
.partnerButton:hover {
border-color: #27272a;
background: #27272a;
color: #fff;
}
.partnerButton img {
width: auto;
max-height: 1.25rem;
}
:global(.dark) .heroButton {
border-color: #52525b;
background: #27272a;
color: #f4f4f5;
}
:global(.dark) .heroButton:hover {
background: #3f3f46;
color: #fff;
}
.heroPreview {
min-width: 0;
animation: dashboard-float 5s ease-in-out infinite;
}
.heroCards {
@@ -67,22 +111,35 @@ section {
width: min(100%, 48rem);
margin-inline: auto;
color: #343a40;
transform: rotate(20deg) scale(0.8) skew(21deg);
}
:global([data-theme="dark"]) .heroCards {
:global(.dark) .heroCards {
color: #dee2e6;
}
.widgetCard {
flex: none;
min-width: 0;
border-radius: 0.75rem;
border: 1px solid color-mix(in srgb, currentcolor 12%, transparent) !important;
border-radius: 0.75rem;
background: color-mix(in srgb, var(--ifm-navbar-background-color) 82%, transparent) !important;
color: inherit;
font: inherit;
background: color-mix(in srgb, var(--ifm-navbar-background-color) 76%, transparent) !important;
box-shadow: 0 5px 12px rgb(31 41 55 / 10%);
backdrop-filter: blur(12px);
box-shadow: 0 12px 28px rgb(31 41 55 / 14%);
}
.heroCards :global(.app-card) {
cursor: default;
}
.heroCards :global(.app-card img) {
transition: transform 150ms ease;
}
.heroCards :global(.app-card:hover img) {
transform: scale(0.66) !important;
}
.resourcesWidget,
@@ -104,9 +161,9 @@ section {
.widgetTitle {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 0.375rem;
min-width: 0;
font-size: 0.8125rem;
font-weight: 700;
}
@@ -122,7 +179,7 @@ section {
color: #087f5b;
}
:global([data-theme="dark"]) .onlineStatus {
:global(.dark) .onlineStatus {
color: #69dbb1;
}
@@ -148,10 +205,10 @@ section {
.resourceTrack {
display: block;
appearance: none;
overflow: hidden;
width: 100%;
height: 0.375rem;
appearance: none;
border: 0;
border-radius: 999px;
background: color-mix(in srgb, currentcolor 16%, transparent);
@@ -165,7 +222,7 @@ section {
.resourceTrack::-moz-progress-bar {
height: 100%;
border-radius: inherit;
background: var(--ifm-color-primary);
background: var(--primary);
}
.resourceMeterCritical .resourceTrack::-webkit-progress-value,
@@ -175,10 +232,10 @@ section {
.downloadProgress {
display: block;
appearance: none;
overflow: hidden;
width: 100%;
height: 0.5rem;
appearance: none;
border: 0;
border-radius: 999px;
background: color-mix(in srgb, currentcolor 18%, transparent);
@@ -230,8 +287,8 @@ section {
font-weight: 700;
}
:global([data-theme="dark"]) .resourceMeterCritical .resourceLabel,
:global([data-theme="dark"]) .containerRowFailed {
:global(.dark) .resourceMeterCritical .resourceLabel,
:global(.dark) .containerRowFailed {
color: #ffe3e3;
}
@@ -240,6 +297,19 @@ section {
animation: failed-container-pulse 1.4s ease-in-out infinite;
}
.heroWave {
display: block;
width: 100%;
height: clamp(5rem, 9vw, 8rem);
margin-top: -1px;
}
@keyframes dashboard-float {
50% {
transform: translateY(10px);
}
}
@keyframes failed-container-pulse {
50% {
opacity: 0.35;
@@ -268,20 +338,48 @@ section {
@media screen and (max-width: 996px) {
.heroBanner {
padding: 2.5rem 1rem;
padding: 3.25rem 2rem 4rem;
}
.heroPreview {
display: none;
}
}
.heroActions > :global(*) {
flex: 1 1 auto;
@media screen and (max-width: 640px) {
.heroBanner {
padding: 2.5rem 2rem 3.5rem;
}
.heroTitle {
font-size: 1.875rem;
line-height: 1.08;
letter-spacing: -0.04em;
}
.heroDescription {
margin-top: 1.4rem;
font-size: 1rem;
line-height: 1.5;
}
.heroActions > * {
flex: 1 1 0;
}
.partnerButton {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.heroPreview,
.statusDotFailed {
animation: none;
}
.heroCards :global(.app-card img),
.heroButton {
transition: none;
}
}
-52
View File
@@ -1,52 +0,0 @@
import React from "react";
import Layout from "@theme/Layout";
import HomepageFeatures from "../components/pages/home/features/features";
import HomepageUserReviews from "../components/pages/home/review-list/review-list";
import HomeHero from "../components/pages/home/hero/hero";
import styles from "./index.module.css";
import BrowserOnly from "@docusaurus/BrowserOnly";
import { SectionContainer } from "@site/src/components/pages/home/container/section-container";
import { DragAndDropShowcase } from "@site/src/components/pages/home/drag-and-drop/drag-and-drop-showcase";
import { AvailableIntegrations } from "@site/src/components/pages/home/integrations/available-integrations";
export default function Home() {
return (
<Layout description="Simplify the management of your server with Homarr - a sleek, modern dashboard that puts all of your apps and services at your fingertips. With Homarr, you can access and control everything in one convenient location. Homarr seamlessly integrates with the apps you've added, providing you with valuable information and giving you complete control. Installation is a breeze, and Homarr supports a wide range of deployment methods.">
<HomeHero />
<main>
<svg
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="none"
viewBox="0 0 1440 320"
className={styles.heroWave}
aria-hidden="true"
focusable="false"
>
<path
fill="var(--ifm-color-primary)"
fillOpacity="1"
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,0L1392,0C1344,0,1248,0,1152,0C1056,0,960,0,864,0C768,0,672,0,576,0C480,0,384,0,288,0C192,0,96,0,48,0L0,0Z"
></path>
</svg>
<SectionContainer>
<HomepageFeatures />
</SectionContainer>
<BrowserOnly fallback={<span>loading...</span>}>
{() => {
const DataflowVisualizationComponent =
require("../components/pages/home/dataflow/dataflow").DataflowVisualizationComponent;
return <DataflowVisualizationComponent />;
}}
</BrowserOnly>
<DragAndDropShowcase />
<AvailableIntegrations />
<SectionContainer>
<HomepageUserReviews />
</SectionContainer>
</main>
</Layout>
);
}
@@ -1,16 +0,0 @@
import React from "react";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import Layout from "@theme/Layout";
import { WorkshopAdmin } from "@site/src/components/workshop/WorkshopAdmin";
import { getRuntimeWorkshopApiUrl } from "@site/src/lib/runtime-config";
export default function WorkshopAdminRoute() {
const { siteConfig } = useDocusaurusContext();
const workshopUrl = (siteConfig.customFields?.workshopUrl as string | undefined) ?? "";
return (
<Layout title="Workshop moderation" noFooter>
<WorkshopAdmin workshopUrl={getRuntimeWorkshopApiUrl(workshopUrl)} />
</Layout>
);
}
-27
View File
@@ -1,27 +0,0 @@
import React from "react";
import BrowserOnly from "@docusaurus/BrowserOnly";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import Layout from "@theme/Layout";
import { WorkshopApp, WorkshopListingFallback } from "@site/src/components/workshop/WorkshopApp";
import { WorkshopErrorBoundary } from "@site/src/components/workshop/WorkshopErrorBoundary";
import { getRuntimeWorkshopApiUrl } from "@site/src/lib/runtime-config";
export default function WorkshopPage() {
const { siteConfig } = useDocusaurusContext();
const configuredWorkshopUrl = (siteConfig.customFields?.workshopUrl as string | undefined) ?? "";
return (
<Layout title="Workshop" description="Community custom CSS and custom widgets for Homarr">
<main className="marketplace bg-background text-foreground min-h-[80vh]">
<BrowserOnly fallback={<WorkshopListingFallback />}>
{() => (
<WorkshopErrorBoundary>
<WorkshopApp workshopUrl={getRuntimeWorkshopApiUrl(configuredWorkshopUrl)} />
</WorkshopErrorBoundary>
)}
</BrowserOnly>
</main>
</Layout>
);
}
-50
View File
@@ -1,50 +0,0 @@
import React from "react";
import Translate, { translate } from "@docusaurus/Translate";
import { PageMetadata } from "@docusaurus/theme-common";
import Layout from "@theme/Layout";
export default function NotFound() {
return (
<>
<PageMetadata
title={translate({
id: "theme.NotFound.title",
message: "Page Not Found",
})}
/>
<Layout>
<main className="container margin-vert--xl">
<div className="row">
<div className="col col--6 col--offset-3">
<img
src="/img/undraw_illustrations/undraw_taken_re_yn20.svg"
alt=""
height={200}
width="auto"
style={{
display: "block",
marginBottom: "2rem",
marginLeft: "auto",
marginRight: "auto",
}}
/>
<h1 className="hero__title">
<Translate id="theme.NotFound.title" description="The title of the 404 page">
You found a dead link!
</Translate>
</h1>
<p>
<Translate id="theme.NotFound.p1" description="The first paragraph of the 404 page">
Oops, you got a dead link. This means, that the page you're looking for is either not available or has
been deleted. We're sorry for the inconvenience. If you believe, that this page should be reachable,
check your URL for any typos.
</Translate>
</p>
</div>
</div>
</main>
</Layout>
</>
);
}
Loaded 100 of 254 files, more files were not shown because too many files have changed in this diff. Show more