merge: stack assistant on mobile boards

This commit is contained in:
Thomas Camlong committed 2026-08-08 21:27:56 +02:00
commit 9fd7a8a05b
1326 files changed
+131355 -46380

No files matched your search

+42
View File
@@ -8,7 +8,49 @@ export const backgroundImageRepeats = createDefinition(["repeat", "repeat-x", "r
export const backgroundImageSizes = createDefinition(["cover", "contain"], {
defaultValue: "cover",
});
export const layoutRoles = createDefinition(["mobile", "base", "custom"], { defaultValue: "custom" });
export type BackgroundImageAttachment = inferDefinitionType<typeof backgroundImageAttachments>;
export type BackgroundImageRepeat = inferDefinitionType<typeof backgroundImageRepeats>;
export type BackgroundImageSize = inferDefinitionType<typeof backgroundImageSizes>;
export type LayoutRole = inferDefinitionType<typeof layoutRoles>;
const MAX_LAYOUT_BREAKPOINT = 32767;
const compareBoardLayouts = (
layoutA: { id: string; breakpoint: number; columnCount: number },
layoutB: { id: string; breakpoint: number; columnCount: number },
) =>
layoutA.breakpoint - layoutB.breakpoint ||
layoutA.columnCount - layoutB.columnCount ||
layoutA.id.localeCompare(layoutB.id);
export const normalizeBoardLayoutRoles = <
TLayout extends { id: string; breakpoint: number; columnCount: number; role?: LayoutRole },
>(
sourceLayouts: TLayout[],
): Array<TLayout & { role: LayoutRole }> => {
const mobileLayouts = sourceLayouts.filter((layout) => layout.role === "mobile");
const baseLayouts = sourceLayouts.filter((layout) => layout.role === "base");
const mobileLayout = mobileLayouts.length === 1 ? mobileLayouts[0] : undefined;
const baseLayout = baseLayouts.length === 1 ? baseLayouts[0] : undefined;
const orderedLayouts =
mobileLayout && baseLayout
? [
mobileLayout,
...sourceLayouts.filter((layout) => layout.role === "custom").toSorted(compareBoardLayouts),
baseLayout,
]
: sourceLayouts.toSorted(compareBoardLayouts);
let previousBreakpoint = -1;
return orderedLayouts.map((layout, index) => {
const remainingLayouts = orderedLayouts.length - index - 1;
const breakpoint =
index === 0
? 0
: Math.min(MAX_LAYOUT_BREAKPOINT - remainingLayouts, Math.max(previousBreakpoint + 1, layout.breakpoint));
const role: LayoutRole = index === 0 ? "mobile" : index === orderedLayouts.length - 1 ? "base" : "custom";
previousBreakpoint = breakpoint;
return { ...layout, breakpoint, role };
});
};
+11 -2
View File
@@ -1,6 +1,15 @@
import type { HomarrDocumentationPath } from "./homarr-docs-sitemap";
const documentationBaseUrl = "https://homarr.dev";
const defaultDocumentationBaseUrl = "https://homarr.dev";
const getDocumentationBaseUrl = () => {
const browserValue =
typeof document === "undefined"
? undefined
: document.querySelector<HTMLMetaElement>('meta[name="homarr-website-url"]')?.content;
const serverValue = typeof process === "undefined" ? undefined : process.env.HOMARR_WEBSITE_URL;
return (browserValue || serverValue || defaultDocumentationBaseUrl).replace(/\/+$/u, "");
};
// Please use the method so the path can be checked!
export const createDocumentationLink = (
@@ -8,7 +17,7 @@ export const createDocumentationLink = (
hashTag?: `#${string}`,
queryParams?: Record<string, string>,
) => {
const url = `${documentationBaseUrl}${path}`;
const url = `${getDocumentationBaseUrl()}${path}`;
const params = queryParams ? `?${new URLSearchParams(queryParams)}` : "";
return `${url}${params}${hashTag ?? ""}`;
};
+32 -1
View File
@@ -1,2 +1,33 @@
export const sectionKinds = ["category", "empty", "dynamic"] as const;
export const sectionKinds = ["empty", "container"] as const;
export type SectionKind = (typeof sectionKinds)[number];
export const boardLanes = ["left", "main", "right"] as const;
export type BoardLane = (typeof boardLanes)[number];
export const rootSectionOffsets = {
left: -1,
main: 0,
right: 1,
} as const satisfies Record<BoardLane, number>;
export const getRootSectionLane = (xOffset: number | null | undefined): BoardLane => {
if (xOffset === rootSectionOffsets.left) return "left";
if (xOffset === rootSectionOffsets.right) return "right";
return "main";
};
export const getBoardLaneColumnCount = (
layout: {
columnCount: number;
leftGutterColumnCount?: number | null;
rightGutterColumnCount?: number | null;
},
lane: BoardLane,
) => {
const total = Math.max(1, Math.floor(layout.columnCount));
const left = Math.min(Math.max(0, Math.floor(layout.leftGutterColumnCount ?? 0)), total - 1);
const right = Math.min(Math.max(0, Math.floor(layout.rightGutterColumnCount ?? 0)), total - left - 1);
if (lane === "left") return left;
if (lane === "right") return right;
return total - left - right;
};
@@ -30,6 +30,21 @@ describe("createDocumentationLink should generate correct URLs", () => {
});
describe("createDocumentationLink parameter validation", () => {
test("uses the server-rendered website URL at runtime", () => {
const meta = document.createElement("meta");
meta.name = "homarr-website-url";
meta.content = "https://docs.example.com/base/";
document.head.append(meta);
try {
expect(createDocumentationLink("/docs/getting-started")).toBe(
"https://docs.example.com/base/docs/getting-started",
);
} finally {
meta.remove();
}
});
test("should work with only path parameter", () => {
const result = createDocumentationLink("/docs/getting-started");
expect(result).toBe("https://homarr.dev/docs/getting-started");
@@ -44,12 +44,12 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
speedtestTracker: ["speedtestTracker"],
uptimeKuma: ["uptimeKuma"],
audioStats: ["navidrome", "audiobookshelf"],
umami: ["umami"],
vpn: getIntegrationKindsByCategory("vpn"),
ups: getIntegrationKindsByCategory("ups"),
archiveTeamWarrior: ["archiveTeamWarrior"],
anchorNote: ["anchor"],
traefik: ["traefik"],
umami: ["umami"],
ups: getIntegrationKindsByCategory("ups"),
};
/** Widgets that remain useful without a configured integration. */