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

@@ -0,0 +1,72 @@
import { Bookmark, ExternalLink } from "lucide-react";
import { registerWidget } from "./WidgetRegistry";
import type { BookmarkListConfig, WidgetComponentProps } from "../types.js";
import { GRID_SIZE } from "../types.js";
function BookmarkListWidget({
config,
widget,
isReadOnly,
}: WidgetComponentProps<BookmarkListConfig>) {
const { links } = config;
return (
<div className="w-full h-full flex flex-col overflow-hidden">
{widget.title && (
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-border shrink-0">
<Bookmark size={11} className="text-accent-brand shrink-0" />
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider truncate">
{widget.title}
</span>
</div>
)}
<div className="flex-1 overflow-auto p-3 gap-1 flex flex-col">
{links.length === 0 && (
<span className="text-xs text-muted-foreground italic">
No bookmarks
</span>
)}
{links.map((link, i) =>
isReadOnly ? (
<div
key={i}
className="flex items-center gap-2 text-xs text-foreground py-1"
>
<Bookmark size={10} className="text-muted-foreground shrink-0" />
<span className="truncate">{link.label || link.url}</span>
</div>
) : (
<a
key={i}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-xs text-foreground hover:text-primary py-1 no-underline group"
>
<Bookmark size={10} className="text-muted-foreground shrink-0" />
<span className="truncate flex-1">{link.label || link.url}</span>
<ExternalLink
size={8}
className="text-muted-foreground/0 group-hover:text-muted-foreground/60 shrink-0"
/>
</a>
),
)}
</div>
</div>
);
}
registerWidget<BookmarkListConfig>({
id: "bookmark_list",
name: "Bookmarks",
description: "A list of quick links",
category: "links",
icon: <Bookmark size={14} />,
defaultConfig: { links: [] },
defaultSize: { w: GRID_SIZE * 9, h: GRID_SIZE * 10 },
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
component: BookmarkListWidget,
});
export { BookmarkListWidget };
@@ -0,0 +1,161 @@
import { useState, useEffect } from "react";
import { CalendarDays, ChevronLeft, ChevronRight } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { registerWidget } from "./WidgetRegistry";
import type { CalendarConfig, WidgetComponentProps } from "../types.js";
import { GRID_SIZE } from "../types.js";
import { WidgetTitle, runVisibleInterval } from "@termix/plugin-sdk/ui";
function getMonthData(year: number, month: number, startOnMonday: boolean) {
const firstDay = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const offset = startOnMonday ? (firstDay === 0 ? 6 : firstDay - 1) : firstDay;
return { daysInMonth, offset };
}
function CalendarWidget({
widget,
config,
}: WidgetComponentProps<CalendarConfig>) {
const { t } = useTranslation();
const { startOnMonday } = config;
const [now, setNow] = useState(new Date());
const [viewYear, setViewYear] = useState(now.getFullYear());
const [viewMonth, setViewMonth] = useState(now.getMonth());
useEffect(() => {
return runVisibleInterval(() => setNow(new Date()), 60_000);
}, []);
const { daysInMonth, offset } = getMonthData(
viewYear,
viewMonth,
startOnMonday,
);
const today = now;
const isCurrentMonth =
viewYear === today.getFullYear() && viewMonth === today.getMonth();
const dayNames = startOnMonday
? [
t("homepage.calMon"),
t("homepage.calTue"),
t("homepage.calWed"),
t("homepage.calThu"),
t("homepage.calFri"),
t("homepage.calSat"),
t("homepage.calSun"),
]
: [
t("homepage.calSun"),
t("homepage.calMon"),
t("homepage.calTue"),
t("homepage.calWed"),
t("homepage.calThu"),
t("homepage.calFri"),
t("homepage.calSat"),
];
const monthLabel = new Intl.DateTimeFormat("en-US", {
month: "long",
year: "numeric",
}).format(new Date(viewYear, viewMonth, 1));
const cells: (number | null)[] = [
...Array(offset).fill(null),
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
];
const prevMonth = () => {
if (viewMonth === 0) {
setViewMonth(11);
setViewYear((y) => y - 1);
} else setViewMonth((m) => m - 1);
};
const nextMonth = () => {
if (viewMonth === 11) {
setViewMonth(0);
setViewYear((y) => y + 1);
} else setViewMonth((m) => m + 1);
};
const accent =
getComputedStyle(document.documentElement)
.getPropertyValue("--accent-brand")
.trim() || "#f59145";
return (
<div className="flex flex-col w-full h-full overflow-hidden select-none">
<WidgetTitle title={widget.title} icon={<CalendarDays size={11} />} />
<div className="flex flex-col flex-1 p-2 gap-1 overflow-hidden">
<div className="flex items-center justify-between shrink-0">
<button
onClick={prevMonth}
className="p-0.5 text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronLeft size={12} />
</button>
<span className="text-[10px] font-semibold text-foreground">
{monthLabel}
</span>
<button
onClick={nextMonth}
className="p-0.5 text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronRight size={12} />
</button>
</div>
<div className="grid grid-cols-7 gap-0 shrink-0">
{dayNames.map((d) => (
<div
key={d}
className="text-center text-[8px] font-semibold text-muted-foreground/70 uppercase py-0.5"
>
{d}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-0 flex-1 content-start">
{cells.map((day, i) => {
if (day === null) return <div key={`e-${i}`} />;
const isToday = isCurrentMonth && day === today.getDate();
return (
<div
key={`d-${day}`}
className="flex items-center justify-center aspect-square"
>
<span
className="text-[9px] font-medium w-5 h-5 flex items-center justify-center rounded-full"
style={
isToday
? { background: accent, color: "#fff" }
: { color: "var(--text-primary, currentColor)" }
}
>
{day}
</span>
</div>
);
})}
</div>
</div>
</div>
);
}
registerWidget<CalendarConfig>({
id: "calendar",
name: "Calendar",
description: "A month calendar with today highlighted",
category: "info",
icon: <CalendarDays size={14} />,
defaultConfig: { startOnMonday: true },
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 9 },
minSize: { w: GRID_SIZE * 7, h: GRID_SIZE * 7 },
component: CalendarWidget,
});
export { CalendarWidget };
@@ -0,0 +1,78 @@
import { useState } from "react";
import { Clock } from "lucide-react";
import { registerWidget } from "./WidgetRegistry";
import {
GRID_SIZE,
type ClockConfig,
type WidgetComponentProps,
} from "../types.js";
import { usePageVisibleInterval } from "../use-page-visible-interval.js";
import { isValidTimezone } from "../clock-timezone";
import { WidgetTitle } from "@termix/plugin-sdk/ui";
function ClockWidget({ widget, config }: WidgetComponentProps<ClockConfig>) {
const { showSeconds, format } = config;
// Configs saved before the edit dialog validated timezones can still hold an
// unusable one (e.g. "America/New York"), which makes toLocaleTimeString throw.
const timezone =
config.timezone && isValidTimezone(config.timezone)
? config.timezone
: undefined;
const [now, setNow] = useState(new Date());
// Minute-level is enough without seconds; pause when the tab is backgrounded.
usePageVisibleInterval(
() => setNow(new Date()),
showSeconds ? 1_000 : 30_000,
);
const opts: Intl.DateTimeFormatOptions = {
hour: "2-digit",
minute: "2-digit",
...(showSeconds ? { second: "2-digit" } : {}),
hour12: format === "12h",
...(timezone ? { timeZone: timezone } : {}),
};
const dateOpts: Intl.DateTimeFormatOptions = {
weekday: "long",
month: "short",
day: "numeric",
...(timezone ? { timeZone: timezone } : {}),
};
const timeStr = now.toLocaleTimeString(undefined, opts);
const dateStr = now.toLocaleDateString(undefined, dateOpts);
return (
<div className="flex flex-col w-full h-full overflow-hidden select-none">
<WidgetTitle title={widget.title} icon={<Clock size={11} />} />
<div className="flex flex-col items-center justify-center flex-1 gap-1 p-3 overflow-auto">
<span className="text-2xl font-bold tabular-nums text-accent-brand">
{timeStr}
</span>
<span className="text-xs text-muted-foreground">{dateStr}</span>
{timezone && (
<span className="text-[10px] text-muted-foreground/60">
{timezone}
</span>
)}
</div>
</div>
);
}
registerWidget<ClockConfig>({
id: "clock",
name: "Clock",
description: "A live clock with configurable timezone",
category: "info",
icon: <Clock size={14} />,
defaultConfig: { showSeconds: true, format: "24h" },
defaultSize: { w: GRID_SIZE * 8, h: GRID_SIZE * 5 },
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
component: ClockWidget,
});
export { ClockWidget };
@@ -0,0 +1,169 @@
import { useEffect, useState } from "react";
import { Timer } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { registerWidget } from "./WidgetRegistry";
import type { CountdownConfig, WidgetComponentProps } from "../types.js";
import { GRID_SIZE } from "../types.js";
import { WidgetTitle } from "@termix/plugin-sdk/ui";
function getAccentColor(): string {
return (
getComputedStyle(document.documentElement)
.getPropertyValue("--accent-brand")
.trim() || "#f59145"
);
}
function decompose(ms: number): {
days: number;
hours: number;
minutes: number;
seconds: number;
} {
const total = Math.max(0, Math.floor(ms / 1000));
const days = Math.floor(total / 86400);
const hours = Math.floor((total % 86400) / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = total % 60;
return { days, hours, minutes, seconds };
}
function UnitBlock({ value, label }: { value: number; label: string }) {
const accent = getAccentColor();
return (
<div className="flex flex-col items-center gap-0.5">
<span
className="text-2xl font-bold tabular-nums leading-none"
style={{ color: accent }}
>
{String(value).padStart(2, "0")}
</span>
<span className="text-[9px] text-muted-foreground uppercase tracking-wider">
{label}
</span>
</div>
);
}
function CountdownWidget({
widget,
config,
}: WidgetComponentProps<CountdownConfig>) {
const { t } = useTranslation();
const { targetDate, label, showDays, showHours } = config;
const [remaining, setRemaining] = useState(0);
const [past, setPast] = useState(false);
useEffect(() => {
const update = () => {
const diff = new Date(targetDate).getTime() - Date.now();
if (diff <= 0) {
setRemaining(0);
setPast(true);
} else {
setRemaining(diff);
setPast(false);
}
};
update();
let intervalId: ReturnType<typeof setInterval> | null = null;
const start = () => {
if (intervalId !== null) return;
intervalId = setInterval(update, 1000);
};
const stop = () => {
if (intervalId === null) return;
clearInterval(intervalId);
intervalId = null;
};
const onVisibility = () => {
if (document.visibilityState === "hidden") {
stop();
return;
}
update();
start();
};
if (document.visibilityState !== "hidden") start();
document.addEventListener("visibilitychange", onVisibility);
return () => {
stop();
document.removeEventListener("visibilitychange", onVisibility);
};
}, [targetDate]);
const titleBar = (
<WidgetTitle title={widget.title} icon={<Timer size={11} />} />
);
if (!targetDate) {
return (
<div className="flex flex-col w-full h-full overflow-hidden">
{titleBar}
<div className="flex items-center justify-center flex-1 text-xs text-muted-foreground">
{t("homepage.countdownNoDate")}
</div>
</div>
);
}
if (past) {
return (
<div className="flex flex-col w-full h-full overflow-hidden">
{titleBar}
<div className="flex flex-col items-center justify-center flex-1 gap-1">
<Timer size={16} className="text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{t("homepage.countdownPast")}
</span>
{label && (
<span className="text-[10px] text-muted-foreground/60">
{label}
</span>
)}
</div>
</div>
);
}
const { days, hours, minutes, seconds } = decompose(remaining);
return (
<div className="flex flex-col w-full h-full overflow-hidden">
{titleBar}
<div className="flex flex-col items-center justify-center flex-1 gap-2 p-2">
{label && (
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
{label}
</span>
)}
<div className="flex items-center gap-3 flex-wrap justify-center">
{showDays && (
<UnitBlock value={days} label={t("homepage.countdownDays")} />
)}
{showHours && (
<UnitBlock value={hours} label={t("homepage.countdownHours")} />
)}
<UnitBlock value={minutes} label={t("homepage.countdownMinutes")} />
<UnitBlock value={seconds} label={t("homepage.countdownSeconds")} />
</div>
</div>
</div>
);
}
registerWidget<CountdownConfig>({
id: "countdown",
name: "Countdown",
description: "Counts down to a target date and time",
category: "info",
icon: <Timer size={14} />,
defaultConfig: { targetDate: "", label: "", showDays: true, showHours: true },
defaultSize: { w: GRID_SIZE * 8, h: GRID_SIZE * 5 },
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
component: CountdownWidget,
});
export { CountdownWidget };
@@ -0,0 +1,200 @@
import { useEffect, useState } from "react";
import { Braces } from "lucide-react";
import { usePluginApi, useTranslation } from "@termix/plugin-sdk/frontend";
import { registerWidget } from "./WidgetRegistry";
import type { CustomApiConfig, WidgetComponentProps } from "../types.js";
import { GRID_SIZE } from "../types.js";
import { WidgetTitle, runVisibleInterval } from "@termix/plugin-sdk/ui";
function resolvePath(obj: unknown, path: string): unknown {
return path.split(".").reduce((acc: unknown, key) => {
if (acc && typeof acc === "object")
return (acc as Record<string, unknown>)[key];
return undefined;
}, obj);
}
function resolveArray(
obj: unknown,
path: string | undefined,
): unknown[] | null {
if (!path) {
if (Array.isArray(obj)) return obj as unknown[];
return null;
}
const val = resolvePath(obj, path);
return Array.isArray(val) ? (val as unknown[]) : null;
}
function CustomApiWidget({
widget,
config,
}: WidgetComponentProps<CustomApiConfig>) {
const { t } = useTranslation();
const api = usePluginApi();
const {
url,
displayField,
label,
unit,
refreshInterval,
displayMode,
jsonPath,
} = config;
const [data, setData] = useState<unknown>(null);
const [error, setError] = useState(false);
const [loading, setLoading] = useState(true);
const interval = Math.max(10, refreshInterval || 60);
const fetchData = async () => {
if (!url) return;
try {
const res = await api.get("/proxy", { params: { url, ttl: interval } });
setData(res.data);
setError(false);
} catch {
setError(true);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
return runVisibleInterval(() => {
void fetchData();
}, interval * 1000);
}, [url, interval]);
const accent =
getComputedStyle(document.documentElement)
.getPropertyValue("--accent-brand")
.trim() || "#f59145";
if (!url) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
{t("homepage.customApiNoUrl")}
</div>
);
}
if (loading) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
{t("common.loading")}
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-red-400">
{t("homepage.customApiError")}
</div>
);
}
const titleBar = (
<WidgetTitle title={widget.title} icon={<Braces size={11} />} />
);
if (displayMode === "value") {
const val = displayField ? resolvePath(data, displayField) : data;
const display =
typeof val === "object" ? JSON.stringify(val) : String(val ?? "—");
return (
<div className="flex flex-col w-full h-full overflow-hidden">
{titleBar}
<div className="flex flex-col items-center justify-center flex-1 gap-1 p-2">
{label && (
<span className="text-[10px] text-muted-foreground uppercase tracking-wider">
{label}
</span>
)}
<span
className="text-2xl font-bold tabular-nums"
style={{ color: accent }}
>
{display}
</span>
{unit && (
<span className="text-[10px] text-muted-foreground">{unit}</span>
)}
</div>
</div>
);
}
if (displayMode === "table") {
const arr = resolveArray(data, jsonPath);
if (!arr) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
{t("homepage.customApiNotArray")}
</div>
);
}
return (
<div className="flex flex-col w-full h-full overflow-hidden">
{titleBar}
<div className="flex-1 overflow-auto p-2">
<table className="w-full text-[9px]">
<tbody>
{arr.slice(0, 50).map((row, i) => {
if (typeof row !== "object" || row === null) {
return (
<tr key={i}>
<td className="py-0.5 pr-2 text-foreground">
{String(row)}
</td>
</tr>
);
}
return (
<tr key={i} className="border-b border-border/30">
{Object.entries(row as Record<string, unknown>)
.slice(0, 4)
.map(([k, v]) => (
<td key={k} className="py-0.5 pr-2">
<span className="text-muted-foreground">{k}: </span>
<span className="text-foreground">
{String(v ?? "")}
</span>
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
// json mode
return (
<div className="flex flex-col w-full h-full overflow-hidden">
{titleBar}
<div className="flex-1 overflow-auto p-2">
<pre className="text-[9px] text-foreground whitespace-pre-wrap break-all leading-relaxed font-mono">
{JSON.stringify(data, null, 2)}
</pre>
</div>
</div>
);
}
registerWidget<CustomApiConfig>({
id: "custom_api",
name: "Custom API",
description: "Fetch and display data from any JSON API",
category: "info",
icon: <Braces size={14} />,
defaultConfig: { url: "", refreshInterval: 60, displayMode: "value" },
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 7 },
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
component: CustomApiWidget,
});
export { CustomApiWidget };
@@ -0,0 +1,151 @@
import { useEffect, useState, useRef } from "react";
import { Link, ExternalLink } from "lucide-react";
import { usePluginApi, useTranslation } from "@termix/plugin-sdk/frontend";
import { registerWidget } from "./WidgetRegistry";
import type {
DashboardLinksConfig,
ServiceLinkRecord,
WidgetComponentProps,
} from "../types.js";
import { GRID_SIZE } from "../types.js";
import { WidgetTitle } from "@termix/plugin-sdk/ui";
function getFaviconUrl(url: string): string {
try {
const domain = new URL(url).hostname;
return `https://www.google.com/s2/favicons?sz=32&domain_url=${encodeURIComponent(domain)}`;
} catch {
return "";
}
}
function LinkTile({
link,
showIcons,
isReadOnly,
}: {
link: ServiceLinkRecord;
showIcons: boolean;
isReadOnly?: boolean;
}) {
const [imgFailed, setImgFailed] = useState(false);
const mouseDownRef = useRef<{ x: number; y: number } | null>(null);
const faviconUrl = showIcons ? getFaviconUrl(link.url) : null;
const content = (
<div className="group flex items-center gap-2 px-2 py-1.5 hover:bg-muted/40 transition-colors overflow-hidden border-b border-border/30">
{showIcons &&
(faviconUrl && !imgFailed ? (
<img
src={faviconUrl}
alt=""
className="w-4 h-4 shrink-0 object-contain"
onError={() => setImgFailed(true)}
/>
) : (
<ExternalLink size={12} className="shrink-0 text-muted-foreground" />
))}
<span className="text-[10px] font-medium text-foreground truncate flex-1">
{link.label}
</span>
</div>
);
if (isReadOnly || !link.url) return content;
return (
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="block no-underline"
onMouseDown={(e) => {
mouseDownRef.current = { x: e.clientX, y: e.clientY };
}}
onClick={(e) => {
const s = mouseDownRef.current;
if (
s &&
(Math.abs(e.clientX - s.x) > 4 || Math.abs(e.clientY - s.y) > 4)
)
e.preventDefault();
mouseDownRef.current = null;
}}
>
{content}
</a>
);
}
function DashboardLinksWidget({
widget,
config,
isReadOnly,
}: WidgetComponentProps<DashboardLinksConfig>) {
const { t } = useTranslation();
const api = usePluginApi();
const { showIcons, columns, maxItems } = config;
const [links, setLinks] = useState<ServiceLinkRecord[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
api
.get("/service-links")
.then((res) => {
const data = res.data as ServiceLinkRecord[];
setLinks(maxItems ? data.slice(0, maxItems) : data);
})
.catch(() => {})
.finally(() => setLoading(false));
}, [maxItems]);
if (loading)
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
{t("common.loading")}
</div>
);
if (links.length === 0)
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
{t("homepage.noDashboardLinks")}
</div>
);
const colClass =
columns === 1
? "grid-cols-1"
: columns === 2
? "grid-cols-2"
: "grid-cols-3";
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle title={widget.title} icon={<Link size={11} />} />
<div className={`grid ${colClass} flex-1 overflow-auto content-start`}>
{links.map((link) => (
<LinkTile
key={link.id}
link={link}
showIcons={showIcons}
isReadOnly={isReadOnly}
/>
))}
</div>
</div>
);
}
registerWidget<DashboardLinksConfig>({
id: "dashboard_links",
name: "Dashboard Links",
description: "Shows your saved dashboard service links",
category: "links",
icon: <Link size={14} />,
defaultConfig: { showIcons: true, columns: 1 },
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
component: DashboardLinksWidget,
});
export { DashboardLinksWidget };
@@ -0,0 +1,46 @@
import { Folder } from "lucide-react";
import { registerWidget } from "./WidgetRegistry";
import type { FolderConfig, WidgetComponentProps } from "../types.js";
import { GRID_SIZE } from "../types.js";
function FolderWidget({ widget, config }: WidgetComponentProps<FolderConfig>) {
const accentColor =
config.color ||
getComputedStyle(document.documentElement)
.getPropertyValue("--accent-brand")
.trim() ||
"#f59145";
return (
<div
className="w-full h-full flex flex-col"
style={{
background: `${accentColor}10`,
borderTop: `2px solid ${accentColor}60`,
}}
>
<div className="flex items-center gap-1.5 px-2 py-1.5 select-none">
<Folder size={11} style={{ color: accentColor }} className="shrink-0" />
<span className="text-xs font-semibold text-foreground truncate">
{widget.title || "Folder"}
</span>
</div>
</div>
);
}
registerWidget<FolderConfig>({
id: "folder",
name: "Folder",
description: "A background card for grouping widgets visually",
category: "links",
icon: <Folder size={14} />,
defaultConfig: { isExpanded: true },
defaultSize: { w: GRID_SIZE * 14, h: GRID_SIZE * 12 },
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
component: FolderWidget as unknown as React.ComponentType<
WidgetComponentProps<FolderConfig>
>,
});
export { FolderWidget };
@@ -0,0 +1,125 @@
import { LayoutGrid } from "lucide-react";
import {
useTranslation,
useHostStatus,
useHosts,
type PluginHostRecord,
} from "@termix/plugin-sdk/frontend";
import { registerWidget } from "./WidgetRegistry";
import type { HostGridConfig, WidgetComponentProps } from "../types.js";
import { GRID_SIZE } from "../types.js";
import { WidgetTitle } from "@termix/plugin-sdk/ui";
function getAccentColor(): string {
return (
getComputedStyle(document.documentElement)
.getPropertyValue("--accent-brand")
.trim() || "#f59145"
);
}
function StatusDot({ status }: { status: string }) {
const color =
status === "online"
? getAccentColor()
: status === "reachable"
? "#fbbf24"
: status === "offline"
? "#ef4444"
: "#6b7280";
return (
<span
className="w-1.5 h-1.5 rounded-full shrink-0 inline-block"
style={{ background: color }}
/>
);
}
function HostGridRow({
host,
showIp,
}: {
host: PluginHostRecord;
showIp: boolean;
}) {
const status = useHostStatus(Number(host.id));
return (
<div className="flex items-center gap-1.5 p-1.5 bg-muted/30 border border-border/40 overflow-hidden">
<StatusDot status={status?.status ?? "unknown"} />
<div className="flex flex-col min-w-0">
<span className="text-[10px] font-medium text-foreground truncate">
{host.name}
</span>
{showIp && (
<span className="text-[9px] text-muted-foreground truncate">
{host.ip}
</span>
)}
</div>
</div>
);
}
function HostGridWidget({
widget,
config,
}: WidgetComponentProps<HostGridConfig>) {
const { t } = useTranslation();
const { hostIds, showIp, columns } = config;
const { hosts: allHosts, loaded } = useHosts();
const hosts =
hostIds.length > 0
? allHosts.filter((h) => hostIds.includes(Number(h.id)))
: allHosts;
if (!loaded) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
{t("common.loading")}
</div>
);
}
if (hosts.length === 0) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
{t("homepage.noHosts")}
</div>
);
}
const gridCols =
columns === 2
? "grid-cols-2"
: columns === 3
? "grid-cols-3"
: "grid-cols-4";
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle title={widget.title} icon={<LayoutGrid size={11} />} />
<div
className={`grid ${gridCols} gap-1.5 p-2 flex-1 content-start overflow-auto`}
>
{hosts.map((host) => (
<HostGridRow key={String(host.id)} host={host} showIp={showIp} />
))}
</div>
</div>
);
}
registerWidget<HostGridConfig>({
id: "host_grid",
name: "Host Grid",
description: "Live status overview grid for all your SSH hosts",
category: "monitoring",
icon: <LayoutGrid size={14} />,
defaultConfig: { hostIds: [], showIp: false, columns: 3 },
defaultSize: { w: GRID_SIZE * 12, h: GRID_SIZE * 8 },
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
component: HostGridWidget,
});
export { HostGridWidget };
@@ -0,0 +1,122 @@
import { Server } from "lucide-react";
import {
useTranslation,
useHost,
useHostStatus,
} from "@termix/plugin-sdk/frontend";
import { ComponentSlot, WidgetTitle } from "@termix/plugin-sdk/ui";
import { registerWidget } from "./WidgetRegistry";
import type {
HostStatusConfig,
HostMetricKey,
WidgetComponentProps,
} from "../types.js";
import { GRID_SIZE } from "../types.js";
function getAccentColor(): string {
return (
getComputedStyle(document.documentElement)
.getPropertyValue("--accent-brand")
.trim() || "#f59145"
);
}
const DEFAULT_METRICS: HostMetricKey[] = ["cpu", "memory"];
function migrateConfig(config: HostStatusConfig): HostMetricKey[] {
if (config.shownMetrics?.length) return config.shownMetrics;
if (config.showMetrics === false) return [];
const metrics: HostMetricKey[] = ["cpu", "memory"];
if (config.showDisk) metrics.push("disk");
return metrics;
}
function HostStatusWidget({
widget,
config,
}: WidgetComponentProps<HostStatusConfig>) {
const { t } = useTranslation();
const shownMetrics = migrateConfig(config);
const { hostId } = config;
const host = useHost(hostId || undefined);
const statusInfo = useHostStatus(hostId || undefined);
const needsMetrics = shownMetrics.length > 0;
if (!hostId) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
{t("homepage.noHostSelected")}
</div>
);
}
const online = statusInfo?.status === "online";
const reachable = statusInfo?.status === "reachable";
const onlineColor = !statusInfo
? "#6b7280"
: online
? getAccentColor()
: reachable
? "#fbbf24"
: "#ef4444";
const onlineLabel = !statusInfo
? t("common.unknown")
: online
? t("common.online")
: reachable
? t("common.reachable", { defaultValue: "Reachable" })
: t("common.offline");
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle title={widget.title} icon={<Server size={11} />} />
<div
className={`flex flex-col gap-2.5 p-3 flex-1 overflow-auto ${!needsMetrics ? "justify-center" : ""}`}
>
<div className="flex items-center gap-2 min-w-0">
<Server size={14} className="text-muted-foreground shrink-0" />
<span className="text-xs font-semibold text-foreground truncate flex-1">
{host?.name ?? `Host #${hostId}`}
</span>
<span className="flex items-center gap-1 shrink-0">
<span
className="w-1.5 h-1.5 shrink-0 rounded-full"
style={{ background: onlineColor }}
/>
<span
className="text-[10px] font-medium"
style={{ color: onlineColor }}
>
{onlineLabel}
</span>
</span>
</div>
{/* Plugins such as host metrics fill in the live numbers. */}
{needsMetrics && (
<ComponentSlot
slotId="homepage.hostMetrics"
props={{ hostId, shownMetrics, online }}
/>
)}
</div>
</div>
);
}
registerWidget<HostStatusConfig>({
id: "host_status",
name: "Host Status",
description: "Shows live status and metrics for an SSH host",
category: "system",
icon: <Server size={14} />,
defaultConfig: { hostId: 0, shownMetrics: DEFAULT_METRICS },
defaultSize: { w: GRID_SIZE * 9, h: GRID_SIZE * 6 },
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
component: HostStatusWidget,
});
export { HostStatusWidget };
@@ -0,0 +1,53 @@
import { Globe } from "lucide-react";
import { registerWidget } from "./WidgetRegistry";
import type { IframeConfig, WidgetComponentProps } from "../types.js";
import { GRID_SIZE } from "../types.js";
import { WidgetTitle } from "@termix/plugin-sdk/ui";
function IframeWidget({
widget,
config,
isReadOnly,
}: WidgetComponentProps<IframeConfig>) {
const { url, scrolling } = config;
if (!url) {
return (
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/50">
<Globe size={24} />
<span className="text-xs">Configure a URL in widget settings</span>
</div>
);
}
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle title={widget.title} icon={<Globe size={11} />} />
<div className="relative flex-1">
<iframe
src={url}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
scrolling={scrolling ? "yes" : "no"}
className="w-full h-full border-0"
title="Embedded content"
/>
{/* Block interaction in read-only mode so the canvas can still pan */}
{isReadOnly && <div className="absolute inset-0 pointer-events-none" />}
</div>
</div>
);
}
registerWidget<IframeConfig>({
id: "iframe_embed",
name: "iFrame Embed",
description: "Embed any URL in an iframe",
category: "info",
icon: <Globe size={14} />,
defaultConfig: { url: "", scrolling: true },
defaultSize: { w: GRID_SIZE * 14, h: GRID_SIZE * 12 },
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
component: IframeWidget,
});
export { IframeWidget };
@@ -0,0 +1,88 @@
import { useState, useRef } from "react";
import { ImageIcon } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { registerWidget } from "./WidgetRegistry";
import type { ImageWidgetConfig, WidgetComponentProps } from "../types.js";
import { GRID_SIZE } from "../types.js";
import { WidgetTitle } from "@termix/plugin-sdk/ui";
const FIT_MAP = {
contain: "object-contain",
cover: "object-cover",
fill: "object-fill",
};
function ImageWidget({
widget,
config,
isReadOnly,
}: WidgetComponentProps<ImageWidgetConfig>) {
const { t } = useTranslation();
const { imageUrl, fit, alt, linkUrl } = config;
const [failed, setFailed] = useState(false);
const mouseDownRef = useRef<{ x: number; y: number } | null>(null);
const titleBar = widget.title ? (
<WidgetTitle title={widget.title} icon={<ImageIcon size={11} />} />
) : null;
const content = (
<div className="flex flex-col w-full h-full overflow-hidden">
{titleBar}
<div className="flex-1 flex items-center justify-center overflow-hidden">
{imageUrl && !failed ? (
<img
src={imageUrl}
alt={alt ?? ""}
className={`w-full h-full ${FIT_MAP[fit]}`}
onError={() => setFailed(true)}
/>
) : (
<div className="flex flex-col items-center gap-1 text-muted-foreground/40">
<ImageIcon size={24} />
<span className="text-[10px]">{t("homepage.noImage")}</span>
</div>
)}
</div>
</div>
);
if (isReadOnly || !linkUrl) return content;
return (
<a
href={linkUrl}
target="_blank"
rel="noopener noreferrer"
className="block w-full h-full"
onMouseDown={(e) => {
mouseDownRef.current = { x: e.clientX, y: e.clientY };
}}
onClick={(e) => {
const s = mouseDownRef.current;
if (
s &&
(Math.abs(e.clientX - s.x) > 4 || Math.abs(e.clientY - s.y) > 4)
)
e.preventDefault();
mouseDownRef.current = null;
}}
>
{content}
</a>
);
}
registerWidget<ImageWidgetConfig>({
id: "image_widget",
name: "Image",
description: "Display any image from a URL",
category: "info",
icon: <ImageIcon size={14} />,
defaultConfig: { imageUrl: "", fit: "contain" },
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
component: ImageWidget,
});
export { ImageWidget };
@@ -0,0 +1,125 @@
import { useRef } from "react";
import { List, ExternalLink } from "lucide-react";
import { registerWidget } from "./WidgetRegistry";
import type {
LinkTreeConfig,
LinkTreeLink,
WidgetComponentProps,
} from "../types.js";
import { GRID_SIZE } from "../types.js";
import { WidgetTitle } from "@termix/plugin-sdk/ui";
function TreeLink({
link,
compact,
isReadOnly,
}: {
link: LinkTreeLink;
compact: boolean;
isReadOnly?: boolean;
}) {
const mouseDownRef = useRef<{ x: number; y: number } | null>(null);
const content = (
<div
className={`group flex items-center gap-1.5 ${compact ? "py-0.5" : "py-1"} pl-2 pr-1 hover:bg-muted/40 transition-colors overflow-hidden`}
>
<ExternalLink
size={9}
className="shrink-0 text-muted-foreground group-hover:text-accent-brand transition-colors"
/>
<div className="flex flex-col min-w-0">
<span className="text-[10px] font-medium text-foreground truncate">
{link.label}
</span>
{!compact && link.description && (
<span className="text-[9px] text-muted-foreground truncate">
{link.description}
</span>
)}
</div>
</div>
);
if (isReadOnly || !link.url) return content;
return (
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="block no-underline"
onMouseDown={(e) => {
mouseDownRef.current = { x: e.clientX, y: e.clientY };
}}
onClick={(e) => {
const s = mouseDownRef.current;
if (
s &&
(Math.abs(e.clientX - s.x) > 4 || Math.abs(e.clientY - s.y) > 4)
)
e.preventDefault();
mouseDownRef.current = null;
}}
>
{content}
</a>
);
}
function LinkTreeWidget({
widget,
config,
isReadOnly,
}: WidgetComponentProps<LinkTreeConfig>) {
const { sections, compact } = config;
if (sections.length === 0) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
No sections configured
</div>
);
}
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle title={widget.title} icon={<List size={11} />} />
<div className="flex-1 overflow-auto">
{sections.map((section, si) => (
<div key={si}>
{section.heading && (
<div className="flex items-center gap-1.5 px-2 pt-2 pb-0.5 border-b border-border/30">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider truncate">
{section.heading}
</span>
</div>
)}
{section.links.map((link, li) => (
<TreeLink
key={li}
link={link}
compact={compact}
isReadOnly={isReadOnly}
/>
))}
</div>
))}
</div>
</div>
);
}
registerWidget<LinkTreeConfig>({
id: "link_tree",
name: "Link Tree",
description: "Grouped links organized into labeled sections",
category: "links",
icon: <List size={14} />,
defaultConfig: { sections: [], compact: false },
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 12 },
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 4 },
component: LinkTreeWidget,
});
export { LinkTreeWidget };
@@ -0,0 +1,124 @@
import { useState, useCallback } from "react";
import { FileText } from "lucide-react";
import { registerWidget } from "./WidgetRegistry";
import type { MarkdownNotesConfig, WidgetComponentProps } from "../types.js";
import { GRID_SIZE } from "../types.js";
import { updateHomepageItem } from "../api.js";
import { WidgetTitle } from "@termix/plugin-sdk/ui";
function renderMarkdown(src: string): string {
return src
.split("\n")
.map((line) => {
if (/^### /.test(line))
return `<h3 class="text-[11px] font-bold mt-2 mb-0.5 text-foreground">${esc(line.slice(4))}</h3>`;
if (/^## /.test(line))
return `<h2 class="text-xs font-bold mt-2 mb-0.5 text-foreground">${esc(line.slice(3))}</h2>`;
if (/^# /.test(line))
return `<h1 class="text-sm font-bold mt-2 mb-0.5 text-foreground">${esc(line.slice(2))}</h1>`;
if (/^- /.test(line))
return `<li class="text-[10px] ml-3 list-disc text-foreground">${inlineFormat(line.slice(2))}</li>`;
if (/^\d+\. /.test(line))
return `<li class="text-[10px] ml-3 list-decimal text-foreground">${inlineFormat(line.replace(/^\d+\. /, ""))}</li>`;
if (line.trim() === "") return "<br/>";
return `<p class="text-[10px] text-foreground leading-relaxed">${inlineFormat(line)}</p>`;
})
.join("");
}
function esc(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function inlineFormat(s: string): string {
return esc(s)
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.+?)\*/g, "<em>$1</em>")
.replace(
/`(.+?)`/g,
'<code class="bg-muted px-0.5 font-mono text-[9px]">$1</code>',
);
}
function MarkdownNotesWidget({
widget,
config,
isReadOnly,
onConfigUpdate,
}: WidgetComponentProps<MarkdownNotesConfig>) {
const { content, backgroundColor, renderMarkdown: doRender } = config;
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(content);
const commitEdit = useCallback(async () => {
setEditing(false);
if (draft === content) return;
try {
await updateHomepageItem(widget.id, {
config: { ...config, content: draft } as unknown as Record<
string,
unknown
>,
});
onConfigUpdate?.({ ...config, content: draft } as unknown as Record<
string,
unknown
>);
} catch {
/* ignore */
}
}, [draft, content, widget.id, config, onConfigUpdate]);
return (
<div
className="flex flex-col w-full h-full overflow-hidden"
style={backgroundColor ? { background: backgroundColor } : undefined}
>
<WidgetTitle title={widget.title} icon={<FileText size={11} />} />
<div className="flex-1 overflow-auto p-2 relative">
{editing ? (
<textarea
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commitEdit}
onMouseDown={(e) => e.stopPropagation()}
className="w-full h-full bg-transparent text-[10px] text-foreground resize-none outline-none border-none leading-relaxed"
style={{ minHeight: "100%" }}
/>
) : doRender ? (
<div
className="text-[10px] leading-relaxed cursor-text"
onDoubleClick={() => {
if (!isReadOnly) setEditing(true);
}}
dangerouslySetInnerHTML={{ __html: renderMarkdown(content || "") }}
/>
) : (
<pre
className="text-[10px] text-foreground leading-relaxed whitespace-pre-wrap break-words font-sans cursor-text"
onDoubleClick={() => {
if (!isReadOnly) setEditing(true);
}}
>
{content}
</pre>
)}
</div>
</div>
);
}
registerWidget<MarkdownNotesConfig>({
id: "markdown_notes",
name: "Markdown Notes",
description: "Notes with optional Markdown rendering and inline editing",
category: "info",
icon: <FileText size={14} />,
defaultConfig: { content: "", renderMarkdown: true },
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
minSize: { w: GRID_SIZE * 3, h: GRID_SIZE * 3 },
component: MarkdownNotesWidget,
});
export { MarkdownNotesWidget };
@@ -0,0 +1,42 @@
import { StickyNote } from "lucide-react";
import { registerWidget } from "./WidgetRegistry";
import {
GRID_SIZE,
type NotesConfig,
type WidgetComponentProps,
} from "../types.js";
import { WidgetTitle } from "@termix/plugin-sdk/ui";
function NotesWidget({ widget, config }: WidgetComponentProps<NotesConfig>) {
const { content, backgroundColor } = config;
return (
<div
className="flex flex-col w-full h-full overflow-hidden"
style={{ background: backgroundColor || undefined }}
>
<WidgetTitle title={widget.title} icon={<StickyNote size={11} />} />
<div className="flex-1 overflow-auto p-3">
<pre className="text-xs text-foreground whitespace-pre-wrap break-words font-sans leading-relaxed select-text w-full">
{content || (
<span className="text-muted-foreground italic">Empty note...</span>
)}
</pre>
</div>
</div>
);
}
registerWidget<NotesConfig>({
id: "notes",
name: "Notes",
description: "A text notes widget",
category: "info",
icon: <StickyNote size={14} />,
defaultConfig: { content: "" },
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
component: NotesWidget,
});
export { NotesWidget };
@@ -0,0 +1,132 @@
import { useEffect, useState } from "react";
import { Wifi } from "lucide-react";
import { usePluginApi, useTranslation } from "@termix/plugin-sdk/frontend";
import { registerWidget } from "./WidgetRegistry";
import type { PingStatusConfig, WidgetComponentProps } from "../types.js";
import { GRID_SIZE } from "../types.js";
import { WidgetTitle, runVisibleInterval } from "@termix/plugin-sdk/ui";
interface PingResult {
ok: boolean;
statusCode: number | null;
latencyMs: number;
}
function StatusDot({ ok, pending }: { ok: boolean | null; pending: boolean }) {
if (pending)
return (
<span className="w-2 h-2 rounded-full bg-muted-foreground/40 shrink-0 inline-block" />
);
return (
<span
className="w-2 h-2 rounded-full shrink-0 inline-block"
style={{ background: ok ? "var(--accent-brand, #f59145)" : "#ef4444" }}
/>
);
}
function PingStatusWidget({
widget,
config,
}: WidgetComponentProps<PingStatusConfig>) {
const { t } = useTranslation();
const api = usePluginApi();
const { urls, refreshInterval, showLatency } = config;
const [results, setResults] = useState<Record<string, PingResult | null>>({});
const [pending, setPending] = useState<Record<string, boolean>>({});
const fetchAll = async () => {
if (urls.length === 0) return;
const newPending: Record<string, boolean> = {};
urls.forEach((u) => {
newPending[u.url] = true;
});
setPending(newPending);
await Promise.all(
urls.map(async (u) => {
try {
const res = await api.get("/ping", { params: { url: u.url } });
const r = res.data as PingResult;
setResults((prev) => ({ ...prev, [u.url]: r }));
} catch {
setResults((prev) => ({
...prev,
[u.url]: { ok: false, statusCode: null, latencyMs: 0 },
}));
} finally {
setPending((prev) => ({ ...prev, [u.url]: false }));
}
}),
);
};
const interval = Math.max(10, refreshInterval || 30);
useEffect(() => {
fetchAll();
return runVisibleInterval(() => {
void fetchAll();
}, interval * 1000);
}, [JSON.stringify(urls), interval]);
if (urls.length === 0) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
{t("homepage.noPingUrls")}
</div>
);
}
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle
title={widget.title || t("homepage.widgetPingStatusName")}
icon={<Wifi size={11} />}
/>
<div className="flex-1 overflow-auto">
{urls.map((u) => {
const r = results[u.url];
const isPending = pending[u.url] ?? false;
return (
<div
key={u.url}
className="flex items-center gap-2 px-2 py-1.5 border-b border-border/30"
>
<StatusDot ok={r?.ok ?? null} pending={isPending} />
<div className="flex flex-col min-w-0 flex-1">
<span className="text-[10px] font-medium text-foreground truncate">
{u.label || u.url}
</span>
{r && (
<span className="text-[9px] text-muted-foreground">
{r.statusCode ?? (r.ok ? "OK" : "ERR")}
</span>
)}
</div>
{showLatency && r && r.latencyMs > 0 && (
<span className="text-[9px] text-muted-foreground shrink-0 tabular-nums">
{r.latencyMs}ms
</span>
)}
</div>
);
})}
</div>
</div>
);
}
registerWidget<PingStatusConfig>({
id: "ping_status",
name: "Ping Status",
description: "Check if URLs or services are reachable",
category: "monitoring",
icon: <Wifi size={14} />,
defaultConfig: { urls: [], refreshInterval: 30, showLatency: true },
defaultSize: { w: GRID_SIZE * 9, h: GRID_SIZE * 7 },
minSize: { w: GRID_SIZE * 3, h: GRID_SIZE * 2 },
component: PingStatusWidget,
});
export { PingStatusWidget };
@@ -0,0 +1,139 @@
import { Zap } from "lucide-react";
import {
useTranslation,
useHostActions,
useHostStatus,
useHosts,
type PluginHostRecord,
} from "@termix/plugin-sdk/frontend";
import { registerWidget } from "./WidgetRegistry";
import type {
QuickConnectConfig,
QuickConnectType,
WidgetComponentProps,
} from "../types.js";
import { GRID_SIZE } from "../types.js";
import { quickConnectTargets } from "../quick-connect-targets.js";
import { WidgetTitle } from "@termix/plugin-sdk/ui";
function openTab(host: PluginHostRecord, type: QuickConnectType) {
window.dispatchEvent(
new CustomEvent("termix:open-tab", {
detail: { hostId: String(host.id), type },
}),
);
}
function StatusDot({ hostId }: { hostId: number }) {
const status = useHostStatus(hostId);
const cls =
status?.status === "online"
? "bg-green-500"
: status?.status === "offline"
? "bg-red-500"
: "bg-muted-foreground/30";
return <span className={`size-1.5 rounded-full shrink-0 ${cls}`} />;
}
function QuickConnectWidget({
widget,
config,
}: WidgetComponentProps<QuickConnectConfig>) {
const { t } = useTranslation();
const { hosts: allHosts, loaded } = useHosts();
const targets = quickConnectTargets(useHostActions());
const hosts =
config.hostIds.length > 0
? allHosts.filter((h) => config.hostIds.includes(Number(h.id)))
: allHosts;
// An empty list means the first tools plugins offer, in their order.
const types =
config.connectionTypes.length > 0
? config.connectionTypes
: (targets
.slice(0, 2)
.map((target) => target.type) as QuickConnectType[]);
if (!loaded) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60">
{t("common.loading")}
</div>
);
}
if (hosts.length === 0) {
return (
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/60">
<Zap size={18} />
<span className="text-xs">{t("homepage.noHosts")}</span>
</div>
);
}
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle title={widget.title} icon={<Zap size={11} />} />
<div className="flex-1 overflow-y-auto p-2 flex flex-col gap-1">
{hosts.map((host) => {
const availableTypes = types.flatMap((type) => {
const target = targets.find((item) => item.type === type);
return target && target.enabled(host) ? [target] : [];
});
if (availableTypes.length === 0) return null;
return (
<div
key={String(host.id)}
className={`flex items-center gap-2 py-1.5 border-b border-border/20 last:border-0 ${config.layout === "grid" ? "flex-col items-start" : ""}`}
>
<div className="flex items-center gap-1.5 flex-1 min-w-0">
{config.showStatus && <StatusDot hostId={Number(host.id)} />}
<span className="text-xs font-medium text-foreground truncate">
{host.name || host.ip}
</span>
</div>
<div className="flex items-center gap-1 shrink-0 flex-wrap">
{availableTypes.map((target) => (
<button
key={target.type}
title={t(target.labelKey, target.type)}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
openTab(host, target.type);
}}
className="p-1.5 text-muted-foreground hover:text-accent-brand hover:bg-accent-brand/10 transition-colors border border-transparent hover:border-accent-brand/20"
>
<target.icon size={12} />
</button>
))}
</div>
</div>
);
})}
</div>
</div>
);
}
registerWidget<QuickConnectConfig>({
id: "quick_connect",
name: "Quick Connect",
description:
"Launch terminal, files, docker, tunnels, and more for your hosts",
category: "system",
icon: <Zap size={14} />,
defaultConfig: {
hostIds: [],
connectionTypes: [],
showStatus: true,
layout: "list",
},
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
component: QuickConnectWidget,
});
@@ -0,0 +1,126 @@
import { useEffect, useState } from "react";
import { Activity } from "lucide-react";
import { useTranslation, activityTarget } from "@termix/plugin-sdk/frontend";
import {
WidgetTitle,
getRecentActivity,
runVisibleInterval,
type RecentActivityItem,
} from "@termix/plugin-sdk/ui";
import { registerWidget } from "./WidgetRegistry";
import type {
RecentActivityConfig,
ActivityType,
WidgetComponentProps,
} from "../types.js";
import { GRID_SIZE } from "../types.js";
function relativeTime(ts: string): string {
const diff = Date.now() - new Date(ts).getTime();
const m = Math.floor(diff / 60_000);
if (m < 1) return "just now";
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
function ActivityIcon({ type }: { type: string }) {
const Icon = activityTarget(type)?.icon ?? Activity;
return <Icon className="size-2.5 shrink-0 text-accent-brand" />;
}
function RecentActivityWidget({
widget,
config,
}: WidgetComponentProps<RecentActivityConfig>) {
const { t } = useTranslation();
const { maxItems, filterTypes, showTimestamp } = config;
const [items, setItems] = useState<RecentActivityItem[]>([]);
const [loading, setLoading] = useState(true);
const fetchData = async () => {
try {
const data = await getRecentActivity(maxItems * 3);
const filtered =
filterTypes.length > 0
? data.filter((i) => filterTypes.includes(i.type as ActivityType))
: data;
setItems(filtered.slice(0, maxItems));
} catch {
/* ignore */
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
return runVisibleInterval(() => {
void fetchData();
}, 60_000);
}, [maxItems, filterTypes.join(",")]);
if (loading) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
{t("common.loading")}
</div>
);
}
if (items.length === 0) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
{t("homepage.noActivity")}
</div>
);
}
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle
title={widget.title || t("homepage.widgetRecentActivityName")}
icon={<Activity size={11} />}
/>
<div className="flex-1 overflow-auto">
{items.map((item) => (
<div
key={item.id}
className="flex items-center gap-2 px-2 py-1.5 border-b border-border/30"
>
<ActivityIcon type={item.type} />
<div className="flex flex-col min-w-0 flex-1">
<span className="text-[10px] font-medium text-foreground truncate">
{item.hostName}
</span>
<span className="text-[9px] text-muted-foreground capitalize">
{item.type.replace("_", " ")}
</span>
</div>
{showTimestamp && (
<span className="text-[9px] text-muted-foreground shrink-0">
{relativeTime(item.timestamp)}
</span>
)}
</div>
))}
</div>
</div>
);
}
registerWidget<RecentActivityConfig>({
id: "recent_activity",
name: "Recent Activity",
description: "Shows recent terminal, Docker, file, and tunnel activity",
category: "system",
icon: <Activity size={14} />,
defaultConfig: { maxItems: 10, filterTypes: [], showTimestamp: true },
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 9 },
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
component: RecentActivityWidget,
});
export { RecentActivityWidget };
@@ -0,0 +1,154 @@
import { useEffect, useState } from "react";
import { Rss, ExternalLink } from "lucide-react";
import { usePluginApi, useTranslation } from "@termix/plugin-sdk/frontend";
import { registerWidget } from "./WidgetRegistry";
import type { RssFeedConfig, WidgetComponentProps } from "../types.js";
import { GRID_SIZE } from "../types.js";
import { WidgetTitle } from "@termix/plugin-sdk/ui";
interface RssItem {
title: string;
link: string;
pubDate: string | null;
description: string | null;
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return "";
try {
const d = new Date(dateStr);
const diffMs = Date.now() - d.getTime();
const diffH = Math.floor(diffMs / 3_600_000);
if (diffH < 1) return "just now";
if (diffH < 24) return `${diffH}h ago`;
const diffD = Math.floor(diffH / 24);
if (diffD < 7) return `${diffD}d ago`;
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
} catch {
return "";
}
}
function RssFeedWidget({
widget,
config,
isReadOnly,
}: WidgetComponentProps<RssFeedConfig>) {
const { t } = useTranslation();
const api = usePluginApi();
const { feedUrl, maxItems, showDescription } = config;
const [items, setItems] = useState<RssItem[]>([]);
const [error, setError] = useState(false);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!feedUrl) return;
let cancelled = false;
const fetchFeed = async () => {
try {
setLoading(true);
setError(false);
const res = await api.get<RssItem[]>("/rss", {
params: { url: feedUrl, max: maxItems ?? 10 },
});
if (!cancelled) setItems(res.data);
} catch {
if (!cancelled) setError(true);
} finally {
if (!cancelled) setLoading(false);
}
};
fetchFeed();
return () => {
cancelled = true;
};
}, [feedUrl, maxItems]);
if (!feedUrl) {
return (
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/50">
<Rss size={24} />
<span className="text-xs">{t("homepage.rssNoFeedUrl")}</span>
</div>
);
}
if (loading && items.length === 0) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/50">
{t("common.loading")}
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-destructive/70 p-3 text-center">
{t("homepage.rssLoadError")}
</div>
);
}
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle
title={widget.title || t("homepage.widgetRssName")}
icon={<Rss size={11} />}
/>
{/* Items */}
<div className="flex-1 overflow-y-auto">
{items.length === 0 ? (
<div className="flex items-center justify-center h-full text-xs text-muted-foreground/50">
{t("homepage.rssNoItems")}
</div>
) : (
items.map((item, i) => (
<div key={i} className="border-b border-border/60 last:border-0">
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
className={`flex flex-col px-3 py-2 gap-0.5 hover:bg-muted/40 transition-colors no-underline ${isReadOnly ? "pointer-events-none" : ""}`}
>
<div className="flex items-start justify-between gap-2">
<span className="text-xs font-medium text-foreground line-clamp-2 leading-snug flex-1">
{item.title}
</span>
<ExternalLink
size={9}
className="text-muted-foreground/40 shrink-0 mt-0.5"
/>
</div>
{showDescription && item.description && (
<span className="text-[10px] text-muted-foreground/70 line-clamp-2">
{item.description.replace(/<[^>]+>/g, "")}
</span>
)}
{item.pubDate && (
<span className="text-[9px] text-muted-foreground/50">
{formatDate(item.pubDate)}
</span>
)}
</a>
</div>
))
)}
</div>
</div>
);
}
registerWidget<RssFeedConfig>({
id: "rss_feed",
name: "RSS Feed",
description: "Display items from an RSS or Atom feed",
category: "links",
icon: <Rss size={14} />,
defaultConfig: { feedUrl: "", maxItems: 10, showDescription: false },
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 10 },
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
component: RssFeedWidget,
});
export { RssFeedWidget };
@@ -0,0 +1,103 @@
import { useRef, useState } from "react";
import { Search } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { registerWidget } from "./WidgetRegistry";
import type {
SearchBarConfig,
SearchEngine,
WidgetComponentProps,
} from "../types.js";
import { GRID_SIZE } from "../types.js";
import { WidgetTitle } from "@termix/plugin-sdk/ui";
const ENGINE_URLS: Record<SearchEngine, string> = {
google: "https://www.google.com/search?q={q}",
duckduckgo: "https://duckduckgo.com/?q={q}",
bing: "https://www.bing.com/search?q={q}",
custom: "",
};
const ENGINE_LABELS: Record<SearchEngine, string> = {
google: "Google",
duckduckgo: "DuckDuckGo",
bing: "Bing",
custom: "Custom",
};
function buildUrl(
engine: SearchEngine,
customUrl: string | undefined,
query: string,
): string {
const template =
engine === "custom" ? (customUrl ?? "") : ENGINE_URLS[engine];
return template.replace("{q}", encodeURIComponent(query));
}
function SearchBarWidget({
widget,
config,
isReadOnly,
}: WidgetComponentProps<SearchBarConfig>) {
const { t } = useTranslation();
const { engine, customUrl, placeholder, openInNewTab } = config;
const [query, setQuery] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!query.trim()) return;
const url = buildUrl(engine, customUrl, query.trim());
if (!url) return;
if (openInNewTab) window.open(url, "_blank", "noopener,noreferrer");
else window.location.href = url;
setQuery("");
};
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle title={widget.title} icon={<Search size={11} />} />
<form
onSubmit={handleSubmit}
className="flex items-center gap-1.5 flex-1 px-2"
onMouseDown={(e) => e.stopPropagation()}
>
<Search size={12} className="text-muted-foreground shrink-0" />
<input
ref={inputRef}
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={
placeholder ||
`${t("homepage.searchPlaceholder")} ${ENGINE_LABELS[engine]}...`
}
disabled={isReadOnly}
className="flex-1 bg-transparent text-xs text-foreground placeholder:text-muted-foreground/60 outline-none border-none min-w-0"
/>
{query.trim() && (
<button
type="submit"
className="shrink-0 text-[10px] font-medium text-accent-brand hover:opacity-80 transition-opacity"
>
{t("homepage.searchGo")}
</button>
)}
</form>
</div>
);
}
registerWidget<SearchBarConfig>({
id: "search_bar",
name: "Search Bar",
description: "Quick search with Google, DuckDuckGo, Bing, or a custom engine",
category: "info",
icon: <Search size={14} />,
defaultConfig: { engine: "google", openInNewTab: true },
defaultSize: { w: GRID_SIZE * 12, h: GRID_SIZE * 3 },
minSize: { w: GRID_SIZE * 6, h: GRID_SIZE * 2 },
component: SearchBarWidget,
});
export { SearchBarWidget };
@@ -0,0 +1,129 @@
import { useState } from "react";
import { SearchCode } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { registerWidget } from "./WidgetRegistry";
import type {
SearchLinksConfig,
SearchLinkShortcut,
WidgetComponentProps,
} from "../types.js";
import { GRID_SIZE } from "../types.js";
import { WidgetTitle } from "@termix/plugin-sdk/ui";
function SearchLinksWidget({
widget,
config,
isReadOnly,
}: WidgetComponentProps<SearchLinksConfig>) {
const { t } = useTranslation();
const { shortcuts } = config;
const [activeIdx, setActiveIdx] = useState<number | null>(null);
const [query, setQuery] = useState("");
const handleOpen = (shortcut: SearchLinkShortcut) => {
if (!query.trim()) return;
const url = shortcut.queryTemplate.replace(
"{q}",
encodeURIComponent(query.trim()),
);
window.open(url, "_blank", "noopener,noreferrer");
setQuery("");
setActiveIdx(null);
};
const handleButtonClick = (i: number) => {
if (isReadOnly) return;
if (activeIdx === i) {
setActiveIdx(null);
setQuery("");
} else {
setActiveIdx(i);
setQuery("");
}
};
if (shortcuts.length === 0) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
{t("homepage.noSearchShortcuts")}
</div>
);
}
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle title={widget.title} icon={<SearchCode size={11} />} />
<div className="flex flex-col flex-1 p-2 gap-1.5 overflow-auto">
<div className="flex flex-wrap gap-1.5">
{shortcuts.map((s, i) => {
const accent =
s.accentColor ||
getComputedStyle(document.documentElement)
.getPropertyValue("--accent-brand")
.trim() ||
"#f59145";
const isActive = activeIdx === i;
return (
<button
key={i}
onClick={() => handleButtonClick(i)}
className="flex items-center gap-1 px-2 py-1 text-[10px] font-medium border transition-colors"
style={
isActive
? { background: accent, borderColor: accent, color: "#fff" }
: {
borderColor: `${accent}50`,
color: "var(--text-primary, currentColor)",
}
}
>
{s.icon && <span className="text-xs">{s.icon}</span>}
{s.label}
</button>
);
})}
</div>
{activeIdx !== null && (
<form
onSubmit={(e) => {
e.preventDefault();
handleOpen(shortcuts[activeIdx]);
}}
className="flex items-center gap-1.5 border border-border/60 px-2 py-1"
onMouseDown={(e) => e.stopPropagation()}
>
<input
autoFocus
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("homepage.searchQueryPlaceholder")}
className="flex-1 bg-transparent text-[10px] text-foreground placeholder:text-muted-foreground/60 outline-none border-none"
/>
<button
type="submit"
className="text-[9px] font-medium text-accent-brand hover:opacity-80"
>
{t("homepage.searchGo")}
</button>
</form>
)}
</div>
</div>
);
}
registerWidget<SearchLinksConfig>({
id: "search_links",
name: "Search Shortcuts",
description: "Quick buttons to search different engines",
category: "links",
icon: <SearchCode size={14} />,
defaultConfig: { shortcuts: [] },
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 7 },
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
component: SearchLinksWidget,
});
export { SearchLinksWidget };
@@ -0,0 +1,160 @@
import { useRef, useState, useEffect } from "react";
import { Grid3x3, ExternalLink } from "lucide-react";
import { registerWidget } from "./WidgetRegistry";
import type {
ServiceGridConfig,
ServiceGridItem,
WidgetComponentProps,
} from "../types.js";
import { GRID_SIZE } from "../types.js";
import { WidgetTitle } from "@termix/plugin-sdk/ui";
function getAccentColor(): string {
return (
getComputedStyle(document.documentElement)
.getPropertyValue("--accent-brand")
.trim() || "#f59145"
);
}
function getFaviconUrl(url: string): string {
try {
const domain = new URL(url).hostname;
return `https://www.google.com/s2/favicons?sz=64&domain_url=${encodeURIComponent(domain)}`;
} catch {
return "";
}
}
const ICON_SIZE_MAP = { sm: 16, md: 24, lg: 32 };
function ServiceTile({
item,
iconSize,
showLabels,
isReadOnly,
}: {
item: ServiceGridItem;
iconSize: number;
showLabels: boolean;
isReadOnly?: boolean;
}) {
const [imgFailed, setImgFailed] = useState(false);
const mouseDownRef = useRef<{ x: number; y: number } | null>(null);
useEffect(() => {
setImgFailed(false);
}, [item.imageUrl, item.url]);
const accent = item.accentColor || getAccentColor();
const imageUrl = item.imageUrl || (item.url ? getFaviconUrl(item.url) : null);
const content = (
<div
className="group flex flex-col items-center justify-center gap-1 p-1.5 w-full h-full border border-border/40 border-t-2 hover:bg-muted/40 transition-colors overflow-hidden"
style={{ borderTopColor: `${accent}60`, background: "var(--color-card)" }}
>
{imageUrl && !imgFailed ? (
<img
src={imageUrl}
alt=""
style={{ width: iconSize, height: iconSize }}
className="object-contain shrink-0"
onError={() => setImgFailed(true)}
/>
) : (
<ExternalLink
size={iconSize * 0.6}
className="text-accent-brand shrink-0"
/>
)}
{showLabels && (
<span className="text-[9px] font-medium text-foreground text-center truncate w-full leading-tight">
{item.label || item.url}
</span>
)}
</div>
);
if (isReadOnly || !item.url) return content;
return (
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="block w-full h-full no-underline"
onMouseDown={(e) => {
mouseDownRef.current = { x: e.clientX, y: e.clientY };
}}
onClick={(e) => {
const s = mouseDownRef.current;
if (
s &&
(Math.abs(e.clientX - s.x) > 4 || Math.abs(e.clientY - s.y) > 4)
)
e.preventDefault();
mouseDownRef.current = null;
}}
>
{content}
</a>
);
}
function ServiceGridWidget({
widget,
config,
isReadOnly,
}: WidgetComponentProps<ServiceGridConfig>) {
const { services, columns, showLabels, iconSize } = config;
if (services.length === 0) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
No services configured
</div>
);
}
const gridCols =
columns === 2
? "grid-cols-2"
: columns === 3
? "grid-cols-3"
: "grid-cols-4";
const size = ICON_SIZE_MAP[iconSize];
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle title={widget.title} icon={<Grid3x3 size={11} />} />
<div
className={`grid ${gridCols} gap-1.5 p-1.5 flex-1 overflow-auto content-start`}
>
{services.map((s, i) => (
<ServiceTile
key={i}
item={s}
iconSize={size}
showLabels={showLabels}
isReadOnly={isReadOnly}
/>
))}
</div>
</div>
);
}
registerWidget<ServiceGridConfig>({
id: "service_grid",
name: "Service Grid",
description: "A grid of service tiles with icons and links",
category: "links",
icon: <Grid3x3 size={14} />,
defaultConfig: { services: [], columns: 3, showLabels: true, iconSize: "md" },
defaultSize: { w: GRID_SIZE * 14, h: GRID_SIZE * 10 },
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 4 },
component: ServiceGridWidget,
});
export { ServiceGridWidget };
@@ -0,0 +1,128 @@
import { useRef, useState, useEffect } from "react";
import { ExternalLink } from "lucide-react";
import { registerWidget } from "./WidgetRegistry";
import type { ServiceLinkConfig, WidgetComponentProps } from "../types.js";
import { GRID_SIZE } from "../types.js";
function getAccentColor(): string {
return (
getComputedStyle(document.documentElement)
.getPropertyValue("--accent-brand")
.trim() || "#f59145"
);
}
function getFaviconUrl(url: string): string {
try {
const domain = new URL(url).hostname;
return `https://www.google.com/s2/favicons?sz=64&domain_url=${encodeURIComponent(domain)}`;
} catch {
return "";
}
}
function ServiceLinkWidget({
widget,
config,
isReadOnly,
}: WidgetComponentProps<ServiceLinkConfig>) {
const { url, description, accentColor, imageUrl, showImage = true } = config;
const resolvedImageUrl = showImage
? imageUrl || (url ? getFaviconUrl(url) : null)
: null;
const mouseDownPosRef = useRef<{ x: number; y: number } | null>(null);
const [imgFailed, setImgFailed] = useState(false);
useEffect(() => {
setImgFailed(false);
}, [resolvedImageUrl]);
let displayLabel = widget.title;
if (!displayLabel && url) {
try {
displayLabel = new URL(url).hostname;
} catch {
displayLabel = url;
}
}
const accentBorder = accentColor || getAccentColor();
const imgSize = Math.min(48, widget.h * 0.3);
const content = (
<div
className="group flex flex-col items-center justify-center gap-1.5 w-full h-full p-2 select-none border-t-2 relative overflow-hidden"
style={{ borderTopColor: `${accentBorder}80` }}
>
{resolvedImageUrl && !imgFailed ? (
<img
src={resolvedImageUrl}
alt=""
className="shrink-0 transition-transform duration-150 group-hover:scale-110 object-contain"
style={{ width: imgSize, height: imgSize }}
onError={() => setImgFailed(true)}
/>
) : showImage ? (
<ExternalLink
className="shrink-0 text-accent-brand transition-transform duration-150 group-hover:scale-110"
style={{ width: imgSize * 0.75, height: imgSize * 0.75 }}
/>
) : null}
<span className="text-xs font-semibold text-foreground text-center leading-tight w-full truncate px-1">
{displayLabel}
</span>
{description && (
<span className="text-[10px] text-muted-foreground text-center w-full truncate px-1">
{description}
</span>
)}
{!isReadOnly && url && (
<ExternalLink
size={10}
className="absolute top-1.5 right-1.5 text-muted-foreground/0 group-hover:text-muted-foreground/60 transition-colors duration-150"
/>
)}
</div>
);
if (isReadOnly || !url) return content;
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="block w-full h-full no-underline"
onMouseDown={(e) => {
mouseDownPosRef.current = { x: e.clientX, y: e.clientY };
}}
onClick={(e) => {
const start = mouseDownPosRef.current;
if (
start &&
(Math.abs(e.clientX - start.x) > 4 ||
Math.abs(e.clientY - start.y) > 4)
) {
e.preventDefault();
}
mouseDownPosRef.current = null;
}}
>
{content}
</a>
);
}
registerWidget<ServiceLinkConfig>({
id: "service_link",
name: "Service Link",
description: "A clickable tile linking to a service URL",
category: "links",
icon: <ExternalLink size={14} />,
defaultConfig: { url: "", description: "", showImage: true },
defaultSize: { w: GRID_SIZE * 8, h: GRID_SIZE * 6 },
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
component: ServiceLinkWidget,
});
export { ServiceLinkWidget };
@@ -0,0 +1,142 @@
import { useState, useRef, useEffect } from "react";
import { Terminal as TerminalIcon, Play } from "lucide-react";
import {
useTranslation,
useHost,
type TabHandle as TerminalHandle,
} from "@termix/plugin-sdk/frontend";
import { PluginComponent, WidgetTitle } from "@termix/plugin-sdk/ui";
import { registerWidget } from "./WidgetRegistry";
import type { SshTerminalConfig, WidgetComponentProps } from "../types.js";
import { GRID_SIZE } from "../types.js";
function SshTerminalWidget({
widget,
config,
}: WidgetComponentProps<SshTerminalConfig>) {
const { t } = useTranslation();
const host = useHost(config.hostId || undefined);
const [started, setStarted] = useState(false);
const terminalRef = useRef<TerminalHandle>(null);
const terminalWrapRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = terminalWrapRef.current;
if (!el || !started) return;
const stopWheel = (e: WheelEvent) => e.stopPropagation();
el.addEventListener("wheel", stopWheel, { capture: true, passive: true });
return () => el.removeEventListener("wheel", stopWheel, { capture: true });
}, [started]);
useEffect(() => {
if (!started) return;
const blurOnOutsideClick = (e: MouseEvent) => {
if (!terminalWrapRef.current?.contains(e.target as Node)) {
const textarea = terminalWrapRef.current?.querySelector("textarea");
textarea?.blur();
}
};
document.addEventListener("mousedown", blurOnOutsideClick, {
capture: true,
});
return () =>
document.removeEventListener("mousedown", blurOnOutsideClick, {
capture: true,
});
}, [started]);
// auto-connect after the host loads if configured
useEffect(() => {
if (host && config.autoConnect) setStarted(true);
}, [host, config.autoConnect]);
if (!config.hostId) {
return (
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/60">
<TerminalIcon size={20} />
<span className="text-xs">{t("homepage.sshTerminalNoHost")}</span>
</div>
);
}
if (!host) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60">
{t("homepage.sshTerminalNoHost")}
</div>
);
}
const hostConfig = {
id: Number(host.id),
ip: host.ip,
port: (host.sshPort as number | undefined) ?? host.port ?? 22,
username: host.username,
authType: host.authType,
terminalConfig: host.terminalConfig,
};
if (!started) {
return (
<div
className="flex flex-col items-center justify-center w-full h-full gap-3"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="flex flex-col items-center gap-1">
<TerminalIcon size={16} className="text-muted-foreground/60" />
<span className="text-xs font-medium text-foreground">
{host.name || host.ip}
</span>
<span className="text-[10px] text-muted-foreground/50">
{host.username}@{host.ip}
</span>
</div>
<button
className="flex items-center gap-1.5 px-3 py-1.5 bg-accent-brand text-white text-xs font-medium hover:opacity-90 transition-opacity"
onClick={() => setStarted(true)}
>
<Play size={11} />
{t("homepage.sshTerminalConnect")}
</button>
</div>
);
}
return (
<div
ref={terminalWrapRef}
className="flex flex-col w-full h-full overflow-hidden"
onMouseDown={(e) => e.stopPropagation()}
>
<WidgetTitle title={widget.title} icon={<TerminalIcon size={11} />} />
<div className="flex-1 overflow-hidden">
<PluginComponent
id="terminal.view"
handleRef={terminalRef}
hostConfig={hostConfig}
isVisible={true}
showTitle={false}
splitScreen={false}
disableAutoFocus={true}
onClose={() => setStarted(false)}
/>
</div>
</div>
);
}
registerWidget<SshTerminalConfig>({
id: "ssh_terminal",
name: "SSH Terminal",
description: "An inline SSH terminal connected to a configured host",
category: "system",
icon: <TerminalIcon size={14} />,
defaultConfig: {
hostId: 0,
autoConnect: false,
},
defaultSize: { w: GRID_SIZE * 16, h: GRID_SIZE * 12 },
minSize: { w: GRID_SIZE * 8, h: GRID_SIZE * 6 },
component: SshTerminalWidget,
});
@@ -0,0 +1,142 @@
import { useEffect, useState } from "react";
import { Server } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { registerWidget } from "./WidgetRegistry";
import type { SystemOverviewConfig, WidgetComponentProps } from "../types.js";
import { GRID_SIZE } from "../types.js";
import {
WidgetTitle,
getDatabaseHealth,
getUptime,
getVersionInfo,
runVisibleInterval,
} from "@termix/plugin-sdk/ui";
interface InfoRowProps {
label: string;
value: string | null | undefined;
valueColor?: string;
}
function InfoRow({ label, value, valueColor }: InfoRowProps) {
if (!value) return null;
return (
<div className="flex justify-between text-[10px] gap-2">
<span className="text-muted-foreground shrink-0">{label}</span>
<span
className="text-foreground truncate text-right font-medium"
style={valueColor ? { color: valueColor } : undefined}
>
{value}
</span>
</div>
);
}
function SystemOverviewWidget({
widget,
config,
}: WidgetComponentProps<SystemOverviewConfig>) {
const { t } = useTranslation();
const { showVersion, showDbHealth, showUptime } = config;
const [version, setVersion] = useState<string | null>(null);
const [updateAvailable, setUpdateAvailable] = useState(false);
const [dbStatus, setDbStatus] = useState<string | null>(null);
const [uptimeFormatted, setUptimeFormatted] = useState<string | null>(null);
const fetchData = async () => {
try {
if (showVersion) {
// `checkRemote=false` makes /version return early with just
// {localVersion, status: "update_check_disabled"}, and the response has
// no `updateAvailable` field in either mode -- so the previous read was
// false twice over. `status` is what the endpoint actually answers with,
// and what the dashboard and profile badges already read.
const info = await getVersionInfo();
setVersion((info?.localVersion as string) ?? null);
setUpdateAvailable(info?.status === "requires_update");
}
if (showDbHealth) {
const health = await getDatabaseHealth();
setDbStatus((health?.status as string) ?? "unknown");
}
if (showUptime) {
const up = await getUptime();
setUptimeFormatted(up.formatted);
}
} catch {
/* ignore */
}
};
useEffect(() => {
fetchData();
return runVisibleInterval(() => {
void fetchData();
}, 300_000);
}, [showVersion, showDbHealth, showUptime]);
const accent =
getComputedStyle(document.documentElement)
.getPropertyValue("--accent-brand")
.trim() || "#f59145";
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle
title={widget.title}
icon={<Server size={11} />}
fallback="Termix"
/>
<div className="flex flex-col gap-1.5 p-3 flex-1 overflow-auto">
{showVersion && (
<InfoRow
label={t("homepage.overviewVersion")}
value={version ?? t("common.loading")}
valueColor={updateAvailable ? "#f97316" : undefined}
/>
)}
{showVersion && updateAvailable && (
<InfoRow
label={t("homepage.overviewUpdateLabel")}
value={t("homepage.overviewUpdateAvailable")}
valueColor="#f97316"
/>
)}
{showDbHealth && (
<InfoRow
label={t("homepage.overviewDatabase")}
value={dbStatus ? dbStatus.toUpperCase() : t("common.loading")}
valueColor={
dbStatus === "ok" || dbStatus === "healthy"
? accent
: dbStatus
? "#ef4444"
: undefined
}
/>
)}
{showUptime && (
<InfoRow
label={t("homepage.overviewUptime")}
value={uptimeFormatted ?? t("common.loading")}
/>
)}
</div>
</div>
);
}
registerWidget<SystemOverviewConfig>({
id: "system_overview",
name: "System Overview",
description: "Termix version, database health, and uptime at a glance",
category: "system",
icon: <Server size={14} />,
defaultConfig: { showVersion: true, showDbHealth: true, showUptime: true },
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 6 },
minSize: { w: GRID_SIZE * 4, h: GRID_SIZE * 3 },
component: SystemOverviewWidget,
});
export { SystemOverviewWidget };
@@ -0,0 +1,143 @@
import { useEffect, useState, useRef } from "react";
import { Clock4 } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { registerWidget } from "./WidgetRegistry";
import type { TermixUptimeConfig, WidgetComponentProps } from "../types.js";
import { GRID_SIZE } from "../types.js";
import {
WidgetTitle,
getUptime,
runVisibleInterval,
} from "@termix/plugin-sdk/ui";
function formatUptime(seconds: number): {
days: number;
hours: number;
minutes: number;
secs: number;
} {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
return { days, hours, minutes, secs };
}
function TermixUptimeWidget({
widget,
config,
}: WidgetComponentProps<TermixUptimeConfig>) {
const { t } = useTranslation();
const { showDetailed } = config;
const [seconds, setSeconds] = useState<number | null>(null);
const [error, setError] = useState(false);
const baseRef = useRef<number | null>(null);
const startRef = useRef<number>(Date.now());
useEffect(() => {
getUptime()
.then((info) => {
baseRef.current = info.uptimeSeconds;
startRef.current = Date.now();
setSeconds(info.uptimeSeconds);
})
.catch(() => setError(true));
return runVisibleInterval(() => {
if (baseRef.current !== null) {
const elapsed = (Date.now() - startRef.current) / 1000;
setSeconds(Math.floor(baseRef.current + elapsed));
}
}, 1000);
}, []);
if (error) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
{t("homepage.uptimeUnavailable")}
</div>
);
}
if (seconds === null) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground">
{t("common.loading")}
</div>
);
}
const { days, hours, minutes, secs } = formatUptime(seconds);
const accent =
getComputedStyle(document.documentElement)
.getPropertyValue("--accent-brand")
.trim() || "#f59145";
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle title={widget.title} icon={<Clock4 size={11} />} />
<div className="flex flex-col items-center justify-center flex-1 gap-1 p-2">
<Clock4 size={14} className="text-muted-foreground" />
<div className="flex items-baseline gap-1 flex-wrap justify-center">
{days > 0 && (
<span
className="text-xl font-bold tabular-nums"
style={{ color: accent }}
>
{days}
<span className="text-xs font-normal text-muted-foreground ml-0.5">
d
</span>
</span>
)}
<span
className="text-xl font-bold tabular-nums"
style={{ color: accent }}
>
{String(hours).padStart(2, "0")}
<span className="text-xs font-normal text-muted-foreground ml-0.5">
h
</span>
</span>
<span
className="text-xl font-bold tabular-nums"
style={{ color: accent }}
>
{String(minutes).padStart(2, "0")}
<span className="text-xs font-normal text-muted-foreground ml-0.5">
m
</span>
</span>
{showDetailed && (
<span
className="text-xl font-bold tabular-nums"
style={{ color: accent }}
>
{String(secs).padStart(2, "0")}
<span className="text-xs font-normal text-muted-foreground ml-0.5">
s
</span>
</span>
)}
</div>
<span className="text-[10px] text-muted-foreground">
{t("homepage.uptimeLabel")}
</span>
</div>
</div>
);
}
registerWidget<TermixUptimeConfig>({
id: "termix_uptime",
name: "Termix Uptime",
description: "Shows how long the Termix server has been running",
category: "system",
icon: <Clock4 size={14} />,
defaultConfig: { showDetailed: false },
defaultSize: { w: GRID_SIZE * 8, h: GRID_SIZE * 4 },
minSize: { w: GRID_SIZE * 3, h: GRID_SIZE * 2 },
component: TermixUptimeWidget,
});
export { TermixUptimeWidget };
@@ -0,0 +1,57 @@
import { Type } from "lucide-react";
import { registerWidget } from "./WidgetRegistry";
import type { TextBannerConfig, WidgetComponentProps } from "../types.js";
import { GRID_SIZE } from "../types.js";
const FONT_SIZE_MAP = {
sm: "text-xs",
md: "text-sm",
lg: "text-base",
xl: "text-xl",
};
const FONT_WEIGHT_MAP = {
normal: "font-normal",
semibold: "font-semibold",
bold: "font-bold",
};
const TEXT_ALIGN_MAP = {
left: "text-left",
center: "text-center",
right: "text-right",
};
function TextBannerWidget({ config }: WidgetComponentProps<TextBannerConfig>) {
const { text, fontSize, textAlign, fontWeight, backgroundColor } = config;
return (
<div
className={`flex items-center w-full h-full px-3 py-2 overflow-hidden ${FONT_SIZE_MAP[fontSize]} ${FONT_WEIGHT_MAP[fontWeight]} ${TEXT_ALIGN_MAP[textAlign]}`}
style={backgroundColor ? { background: backgroundColor } : undefined}
>
<span className="whitespace-pre-wrap break-words w-full text-foreground leading-tight">
{text || "Banner text"}
</span>
</div>
);
}
registerWidget<TextBannerConfig>({
id: "text_banner",
name: "Text Banner",
description: "A styled text label for sections and headings",
category: "info",
icon: <Type size={14} />,
defaultConfig: {
text: "",
fontSize: "lg",
textAlign: "center",
fontWeight: "semibold",
},
defaultSize: { w: GRID_SIZE * 14, h: GRID_SIZE * 3 },
minSize: { w: GRID_SIZE * 3, h: GRID_SIZE * 2 },
component: TextBannerWidget,
});
export { TextBannerWidget };
@@ -0,0 +1,198 @@
import { useEffect, useState } from "react";
import {
Cloud,
CloudRain,
Sun,
CloudSnow,
Wind,
Thermometer,
} from "lucide-react";
import { registerWidget } from "./WidgetRegistry";
import type { WeatherConfig, WidgetComponentProps } from "../types.js";
import { GRID_SIZE } from "../types.js";
import { WidgetTitle, runVisibleInterval } from "@termix/plugin-sdk/ui";
interface WttrCurrent {
temp_C: string;
FeelsLikeC: string;
weatherDesc: { value: string }[];
weatherCode: string;
}
interface WttrDay {
mintempC: string;
maxtempC: string;
hourly: { weatherDesc: { value: string }[]; weatherCode: string }[];
}
interface WttrResponse {
nearest_area: {
areaName: { value: string }[];
country: { value: string }[];
}[];
current_condition: WttrCurrent[];
weather: WttrDay[];
}
function weatherIcon(code: string, size = 20) {
const n = Number(code);
if (n === 113) return <Sun size={size} className="text-yellow-400" />;
if (n <= 119) return <Cloud size={size} className="text-gray-400" />;
if (n <= 143) return <Cloud size={size} className="text-gray-400" />;
if (n <= 266) return <CloudRain size={size} className="text-blue-400" />;
if (n <= 335) return <CloudSnow size={size} className="text-blue-200" />;
if (n <= 389) return <CloudRain size={size} className="text-blue-500" />;
return <Wind size={size} className="text-muted-foreground" />;
}
function celsiusToF(c: number) {
return Math.round((c * 9) / 5 + 32);
}
function WeatherWidget({
widget,
config,
}: WidgetComponentProps<WeatherConfig>) {
const { location, unit, showForecast } = config;
const [data, setData] = useState<WttrResponse | null>(null);
const [error, setError] = useState(false);
useEffect(() => {
if (!location) return;
let cancelled = false;
const fetchWeather = async () => {
try {
setError(false);
const res = await fetch(
`https://wttr.in/${encodeURIComponent(location)}?format=j1`,
);
if (!res.ok) throw new Error("bad response");
const json: WttrResponse = await res.json();
if (!cancelled) setData(json);
} catch {
if (!cancelled) setError(true);
}
};
fetchWeather();
const stop = runVisibleInterval(
() => {
void fetchWeather();
},
10 * 60 * 1000,
);
return () => {
cancelled = true;
stop();
};
}, [location]);
if (!location) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60 p-3 text-center">
Configure a location in widget settings
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-destructive/70 p-3 text-center">
Could not load weather for "{location}"
</div>
);
}
if (!data) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/50">
Loading...
</div>
);
}
const current = data.current_condition[0];
const area = data.nearest_area[0];
const cityName = area?.areaName[0]?.value ?? location;
const tempC = Number(current.temp_C);
const feelsC = Number(current.FeelsLikeC);
const displayTemp = unit === "F" ? `${celsiusToF(tempC)}°F` : `${tempC}°C`;
const displayFeels = unit === "F" ? `${celsiusToF(feelsC)}°F` : `${feelsC}°C`;
const desc = current.weatherDesc[0]?.value ?? "";
const code = current.weatherCode;
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle title={widget.title} icon={<Sun size={11} />} />
<div className="flex flex-col flex-1 p-3 gap-2 overflow-auto">
{/* Current conditions */}
<div className="flex items-center gap-3 flex-1 min-h-0">
<div className="shrink-0">{weatherIcon(code, 32)}</div>
<div className="flex flex-col min-w-0">
<span className="text-2xl font-bold text-foreground leading-none">
{displayTemp}
</span>
<span className="text-[10px] text-muted-foreground truncate">
{desc}
</span>
<span className="text-[10px] text-muted-foreground/70">
Feels like {displayFeels}
</span>
</div>
</div>
<div className="flex items-center gap-1">
<Thermometer
size={10}
className="text-muted-foreground/60 shrink-0"
/>
<span className="text-[11px] font-semibold text-foreground truncate">
{cityName}
</span>
</div>
{/* 3-day forecast */}
{showForecast && data.weather.length >= 3 && (
<div className="flex gap-1 border-t border-border pt-2 mt-auto shrink-0">
{data.weather.slice(0, 3).map((day, i) => {
const hourly = day.hourly[4] ?? day.hourly[0];
const minC = Number(day.mintempC);
const maxC = Number(day.maxtempC);
const lo = unit === "F" ? `${celsiusToF(minC)}°` : `${minC}°`;
const hi = unit === "F" ? `${celsiusToF(maxC)}°` : `${maxC}°`;
const dayLabel = i === 0 ? "Today" : i === 1 ? "Tmrw" : "+2";
return (
<div
key={i}
className="flex-1 flex flex-col items-center gap-0.5"
>
<span className="text-[9px] text-muted-foreground/70">
{dayLabel}
</span>
{weatherIcon(hourly?.weatherCode ?? "113", 12)}
<span className="text-[9px] text-foreground">{hi}</span>
<span className="text-[9px] text-muted-foreground/60">
{lo}
</span>
</div>
);
})}
</div>
)}
</div>
</div>
);
}
registerWidget<WeatherConfig>({
id: "weather",
name: "Weather",
description: "Live weather for any location",
category: "info",
icon: <Sun size={14} />,
defaultConfig: { location: "", unit: "C", showForecast: true },
defaultSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 7 },
minSize: { w: GRID_SIZE * 2, h: GRID_SIZE * 2 },
component: WeatherWidget,
});
export { WeatherWidget };
@@ -0,0 +1,40 @@
import {
homepageWidgetType,
useHomepageWidgetTypes,
type HomepageWidgetContribution,
} from "@termix/plugin-sdk/frontend";
import type { WidgetTypeDefinition, WidgetTypeId } from "../types.js";
/**
* Each widget module calls this at import time (before activate(app) runs),
* so the definitions are queued here and app.registerHomepageWidget is
* called for each one from activate. Core owns the actual registry, the same
* one other plugins (docker, tunnels, host-metrics, file-manager) register
* their own widgets into.
*/
const queued: WidgetTypeDefinition[] = [];
export function registerWidget<C>(def: WidgetTypeDefinition<C>): void {
queued.push(def as unknown as WidgetTypeDefinition);
}
/** Called once from activate(app); drains the queue. */
export function drainQueuedWidgets(): WidgetTypeDefinition[] {
return queued.splice(0, queued.length);
}
function toDefinition(
widget: HomepageWidgetContribution | undefined,
): WidgetTypeDefinition | undefined {
return widget as unknown as WidgetTypeDefinition | undefined;
}
export function getWidgetType(
id: WidgetTypeId,
): WidgetTypeDefinition | undefined {
return toDefinition(homepageWidgetType(id));
}
export function useWidgetTypes(): WidgetTypeDefinition[] {
return useHomepageWidgetTypes() as unknown as WidgetTypeDefinition[];
}
@@ -0,0 +1,169 @@
import { useState } from "react";
import { Pencil, Trash2, GripVertical } from "lucide-react";
import type { CanvasWidget } from "../types.js";
import { getWidgetType, useWidgetTypes } from "./WidgetRegistry";
import { PluginViewPlaceholder } from "@termix/plugin-sdk/ui";
interface WidgetShellProps {
widget: CanvasWidget;
isLocked?: boolean;
isReadOnly?: boolean;
isDragging?: boolean;
isResizing?: boolean;
onStartDrag?: (e: React.MouseEvent, widget: CanvasWidget) => void;
onStartResize?: (e: React.MouseEvent, widget: CanvasWidget) => void;
onDelete?: (id: number) => void;
onEdit?: (id: number) => void;
onConfigUpdate?: (config: Record<string, unknown>) => void;
children?: React.ReactNode;
}
export function WidgetShell({
widget,
isLocked,
isReadOnly,
isDragging,
isResizing,
onStartDrag,
onStartResize,
onDelete,
onEdit,
onConfigUpdate,
children,
}: WidgetShellProps) {
const [hovered, setHovered] = useState(false);
// A plugin widget appears once its plugin starts.
useWidgetTypes();
const typeDef = getWidgetType(widget.typeId);
const showControls =
hovered && !isLocked && !isReadOnly && !isDragging && !isResizing;
const WidgetComponent = typeDef?.component;
return (
<div
data-widget={widget.id}
className={`absolute overflow-hidden select-none transition-shadow duration-150 ${
hovered && !isLocked && !isReadOnly
? "ring-1 ring-accent-brand/30 shadow-md"
: "ring-1 ring-border/60"
}`}
style={{
left: widget.x,
top: widget.y,
width: widget.w,
height: widget.h,
background: "var(--color-card)",
opacity: isDragging ? 0.85 : 1,
transform: isDragging ? "scale(1.02)" : undefined,
boxShadow:
isDragging || isResizing ? "0 8px 32px rgba(0,0,0,0.25)" : undefined,
cursor:
isLocked || isReadOnly ? "default" : isDragging ? "grabbing" : "grab",
}}
onWheel={(e) => {
// Find the nearest scrollable ancestor within this widget
let el = e.target as HTMLElement | null;
while (el && el !== e.currentTarget) {
const canScrollY = el.scrollHeight > el.clientHeight;
const canScrollX = el.scrollWidth > el.clientWidth;
if (canScrollY || canScrollX) {
e.stopPropagation();
break;
}
el = el.parentElement;
}
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onMouseDown={
!isLocked && !isReadOnly ? (e) => onStartDrag?.(e, widget) : undefined
}
>
{/* Widget content */}
{children ??
(WidgetComponent ? (
<WidgetComponent
widget={widget}
config={widget.config as Record<string, unknown>}
isReadOnly={isReadOnly}
onConfigUpdate={onConfigUpdate}
/>
) : (
<PluginViewPlaceholder kind="card" viewId={widget.typeId} compact />
))}
{/* Hover controls */}
{showControls && (
<>
{/* Top-right action bar */}
<div
className="absolute top-1 right-1 flex items-center gap-0.5 z-10"
style={{ opacity: hovered ? 1 : 0, transition: "opacity 0.1s" }}
>
<div className="p-1 text-muted-foreground/40 cursor-grab">
<GripVertical size={10} />
</div>
<button
className="p-1 bg-background/90 hover:bg-background border border-border text-muted-foreground hover:text-foreground transition-colors"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onEdit?.(widget.id);
}}
title="Edit"
>
<Pencil size={10} />
</button>
<button
className="p-1 bg-background/90 hover:bg-destructive border border-border text-muted-foreground hover:text-destructive-foreground transition-colors"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onDelete?.(widget.id);
}}
title="Delete"
>
<Trash2 size={10} />
</button>
</div>
{/* Resize handle — bottom right */}
<div
className="absolute bottom-0 right-0 w-5 h-5 cursor-se-resize z-10 flex items-end justify-end pb-0.5 pr-0.5"
onMouseDown={(e) => {
e.stopPropagation();
onStartResize?.(e, widget);
}}
>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
className="text-muted-foreground/50"
>
<line
x1="2"
y1="10"
x2="10"
y2="2"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
<line
x1="6"
y1="10"
x2="10"
y2="6"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</div>
</>
)}
</div>
);
}