feat: finish the homepage plugin, move service links and canvas out of core (B19)

This commit is contained in:
LukeGus committed 2026-09-24 15:07:25 -05:00
1 parent 21937a6b02
commit d4ec444558
256 files changed
+27365 -9077

No files matched your search

+15 -9
View File
@@ -9,6 +9,7 @@ import {
import { PRESETS, type UiPreset } from "@/types/ui-preferences";
import { sanitizeHostSidebarPreferences } from "@/types/host-sidebar-preferences";
import { sanitizeCredentialSidebarPreferences } from "@/types/credential-sidebar-preferences";
import { getRegisteredDashboardCard } from "@/dashboard/dashboard-cards-registry";
/**
* Seeding a preset into the stores that already own their settings.
@@ -25,31 +26,36 @@ import { sanitizeCredentialSidebarPreferences } from "@/types/credential-sidebar
* overwrites layouts the user may have arranged by hand.
*/
const DASHBOARD_SLOT_HEIGHTS: Record<string, number | null> = {
// Core's own cards. A plugin's card carries its own height and panel on its
// registration (registerDashboardCard's defaultHeight/defaultPanel), read
// below, so this preset never has to spell the plugin's card id itself.
const CORE_DASHBOARD_SLOT_HEIGHTS: Record<string, number | null> = {
stats_bar: 96,
counters_bar: 48,
quick_actions: 160,
host_status: null,
recent_activity: null,
network_graph: 360,
service_links: 200,
homepage_preview: 320,
};
/** Cards that belong in the narrower side column rather than the main one. */
const DASHBOARD_SIDE_CARDS = new Set(["recent_activity", "service_links"]);
/** Core cards that belong in the narrower side column rather than the main one. */
const CORE_DASHBOARD_SIDE_CARDS = new Set(["recent_activity"]);
function buildDashboardSlots(cardIds: string[]) {
export function buildDashboardSlots(cardIds: string[]) {
let mainOrder = 0;
let sideOrder = 0;
return cardIds.map((id) => {
const panel = DASHBOARD_SIDE_CARDS.has(id) ? "side" : "main";
const registered = getRegisteredDashboardCard(id);
const panel =
registered?.defaultPanel ??
(CORE_DASHBOARD_SIDE_CARDS.has(id) ? "side" : "main");
const height =
registered?.defaultHeight ?? CORE_DASHBOARD_SLOT_HEIGHTS[id] ?? null;
return {
key: `${id}_0`,
id,
panel,
order: panel === "side" ? sideOrder++ : mainOrder++,
height: DASHBOARD_SLOT_HEIGHTS[id] ?? null,
height,
};
});
}
-16
View File
@@ -1,16 +0,0 @@
export function normalizeServiceLinkUrl(value: string): string {
const trimmed = value.trim();
if (!trimmed) return "";
return /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)
? trimmed
: `http://${trimmed}`;
}
export function isValidServiceLinkUrl(value: string): boolean {
try {
const parsed = new URL(normalizeServiceLinkUrl(value));
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
-12
View File
@@ -36,18 +36,6 @@ export const DASHBOARD_CARDS: DashboardCardConfig[] = [
description: "Feed of recent connection events",
defaultEnabled: true,
},
{
id: "service_links",
label: "Service Links",
description: "Clickable buttons linking to services on your servers",
defaultEnabled: false,
},
{
id: "homepage_preview",
label: "Homepage",
description: "Scaled preview of your Homepage canvas",
defaultEnabled: false,
},
];
export const ACCENT_PRESET_COLORS = [
+50
View File
@@ -0,0 +1,50 @@
/**
* setInterval that pauses while the document is hidden and fires once on resume.
* Returns a cleanup function for useEffect.
*/
export function runVisibleInterval(
tick: () => void,
intervalMs: number,
): () => void {
let intervalId: ReturnType<typeof setInterval> | null = null;
const start = () => {
if (intervalId !== null || intervalMs <= 0) return;
intervalId = setInterval(() => {
if (document.visibilityState === "hidden") return;
tick();
}, intervalMs);
};
const stop = () => {
if (intervalId === null) return;
clearInterval(intervalId);
intervalId = null;
};
const onVisibility = () => {
if (document.visibilityState === "hidden") {
stop();
return;
}
tick();
start();
};
if (
typeof document === "undefined" ||
document.visibilityState !== "hidden"
) {
start();
}
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", onVisibility);
}
return () => {
stop();
if (typeof document !== "undefined") {
document.removeEventListener("visibilitychange", onVisibility);
}
};
}
+20
View File
@@ -0,0 +1,20 @@
import type { LucideIcon } from "lucide-react";
interface WidgetTitleProps {
title: string | null | undefined;
icon?: React.ReactElement<LucideIcon>;
fallback?: string;
}
export function WidgetTitle({ title, icon, fallback }: WidgetTitleProps) {
const label = title || fallback;
if (!label) return null;
return (
<div className="flex items-center gap-1.5 px-2 py-1.5 border-b border-border/50 shrink-0">
{icon && <span className="text-accent-brand shrink-0">{icon}</span>}
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider truncate">
{label}
</span>
</div>
);
}