All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
278 lines
6.9 KiB
TypeScript
278 lines
6.9 KiB
TypeScript
/**
|
|
* WidgetPicker — Dialog to browse available widgets and add them to the dashboard.
|
|
*/
|
|
|
|
import { useState, useCallback } from "react";
|
|
import type { ReactElement } from "react";
|
|
import type { WidgetPlacement } from "@mosaic/shared";
|
|
import { getAllWidgets, type WidgetDefinition } from "./WidgetRegistry";
|
|
|
|
export interface WidgetPickerProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
onAddWidget: (placement: WidgetPlacement) => void;
|
|
currentLayout: WidgetPlacement[];
|
|
}
|
|
|
|
/** Generate a unique widget ID: "WidgetType-<random>" */
|
|
function generateWidgetId(widgetName: string): string {
|
|
const suffix = Math.random().toString(36).slice(2, 8);
|
|
return `${widgetName}-${suffix}`;
|
|
}
|
|
|
|
/** Find the first open Y position at x=0 that doesn't overlap */
|
|
function findNextY(layout: WidgetPlacement[]): number {
|
|
if (layout.length === 0) return 0;
|
|
let maxBottom = 0;
|
|
for (const item of layout) {
|
|
const bottom = item.y + item.h;
|
|
if (bottom > maxBottom) maxBottom = bottom;
|
|
}
|
|
return maxBottom;
|
|
}
|
|
|
|
function WidgetPickerItem({
|
|
widget,
|
|
onAdd,
|
|
}: {
|
|
widget: WidgetDefinition;
|
|
onAdd: () => void;
|
|
}): ReactElement {
|
|
const [hovered, setHovered] = useState(false);
|
|
|
|
return (
|
|
<div
|
|
onMouseEnter={(): void => {
|
|
setHovered(true);
|
|
}}
|
|
onMouseLeave={(): void => {
|
|
setHovered(false);
|
|
}}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 12,
|
|
padding: "12px 16px",
|
|
borderRadius: "var(--r)",
|
|
background: hovered ? "var(--surface-2)" : "transparent",
|
|
transition: "background 0.12s ease",
|
|
}}
|
|
>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div
|
|
style={{
|
|
fontSize: "0.9rem",
|
|
fontWeight: 600,
|
|
color: "var(--text)",
|
|
}}
|
|
>
|
|
{widget.displayName}
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontSize: "0.78rem",
|
|
color: "var(--muted)",
|
|
marginTop: 2,
|
|
lineHeight: 1.4,
|
|
}}
|
|
>
|
|
{widget.description}
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontSize: "0.7rem",
|
|
color: "var(--muted)",
|
|
marginTop: 4,
|
|
opacity: 0.7,
|
|
}}
|
|
>
|
|
Default size: {widget.defaultWidth}×{widget.defaultHeight}
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={onAdd}
|
|
style={{
|
|
padding: "6px 12px",
|
|
borderRadius: "var(--r)",
|
|
border: "1px solid var(--primary)",
|
|
background: hovered ? "var(--primary)" : "transparent",
|
|
color: hovered ? "#fff" : "var(--primary-l)",
|
|
fontSize: "0.78rem",
|
|
fontWeight: 500,
|
|
cursor: "pointer",
|
|
transition: "all 0.12s ease",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
Add
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function WidgetPicker({
|
|
open,
|
|
onClose,
|
|
onAddWidget,
|
|
currentLayout,
|
|
}: WidgetPickerProps): ReactElement | null {
|
|
const allWidgets = getAllWidgets();
|
|
const [search, setSearch] = useState("");
|
|
|
|
const filtered = search
|
|
? allWidgets.filter(
|
|
(w) =>
|
|
w.displayName.toLowerCase().includes(search.toLowerCase()) ||
|
|
w.description.toLowerCase().includes(search.toLowerCase())
|
|
)
|
|
: allWidgets;
|
|
|
|
const handleAdd = useCallback(
|
|
(widget: WidgetDefinition) => {
|
|
const placement: WidgetPlacement = {
|
|
i: generateWidgetId(widget.name),
|
|
x: 0,
|
|
y: findNextY(currentLayout),
|
|
w: widget.defaultWidth,
|
|
h: widget.defaultHeight,
|
|
minW: widget.minWidth,
|
|
minH: widget.minHeight,
|
|
};
|
|
if (widget.maxWidth !== undefined) placement.maxW = widget.maxWidth;
|
|
if (widget.maxHeight !== undefined) placement.maxH = widget.maxHeight;
|
|
onAddWidget(placement);
|
|
},
|
|
[currentLayout, onAddWidget]
|
|
);
|
|
|
|
if (!open) return null;
|
|
|
|
return (
|
|
<>
|
|
{/* Backdrop */}
|
|
<div
|
|
onClick={onClose}
|
|
style={{
|
|
position: "fixed",
|
|
inset: 0,
|
|
background: "rgba(0,0,0,0.4)",
|
|
zIndex: 999,
|
|
}}
|
|
aria-hidden="true"
|
|
/>
|
|
|
|
{/* Panel */}
|
|
<div
|
|
role="dialog"
|
|
aria-label="Add Widget"
|
|
style={{
|
|
position: "fixed",
|
|
top: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
width: 380,
|
|
maxWidth: "90vw",
|
|
background: "var(--surface)",
|
|
borderLeft: "1px solid var(--border)",
|
|
boxShadow: "var(--shadow-lg)",
|
|
zIndex: 1000,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
{/* Header */}
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
padding: "16px 20px",
|
|
borderBottom: "1px solid var(--border)",
|
|
}}
|
|
>
|
|
<h2
|
|
style={{
|
|
fontSize: "1.1rem",
|
|
fontWeight: 700,
|
|
color: "var(--text)",
|
|
margin: 0,
|
|
}}
|
|
>
|
|
Add Widget
|
|
</h2>
|
|
<button
|
|
onClick={onClose}
|
|
aria-label="Close"
|
|
style={{
|
|
background: "none",
|
|
border: "none",
|
|
color: "var(--muted)",
|
|
cursor: "pointer",
|
|
padding: 4,
|
|
fontSize: "1.2rem",
|
|
lineHeight: 1,
|
|
}}
|
|
>
|
|
×
|
|
</button>
|
|
</div>
|
|
|
|
{/* Search */}
|
|
<div style={{ padding: "12px 20px 0" }}>
|
|
<input
|
|
type="text"
|
|
placeholder="Search widgets..."
|
|
value={search}
|
|
onChange={(e): void => {
|
|
setSearch(e.target.value);
|
|
}}
|
|
style={{
|
|
width: "100%",
|
|
padding: "8px 12px",
|
|
borderRadius: "var(--r)",
|
|
border: "1px solid var(--border)",
|
|
background: "var(--surface-2)",
|
|
color: "var(--text)",
|
|
fontSize: "0.85rem",
|
|
outline: "none",
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Widget list */}
|
|
<div
|
|
style={{
|
|
flex: 1,
|
|
overflowY: "auto",
|
|
padding: "8px 12px",
|
|
}}
|
|
>
|
|
{filtered.length === 0 ? (
|
|
<div
|
|
style={{
|
|
padding: 20,
|
|
textAlign: "center",
|
|
color: "var(--muted)",
|
|
fontSize: "0.85rem",
|
|
}}
|
|
>
|
|
No widgets found
|
|
</div>
|
|
) : (
|
|
filtered.map((widget) => (
|
|
<WidgetPickerItem
|
|
key={widget.name}
|
|
widget={widget}
|
|
onAdd={(): void => {
|
|
handleAdd(widget);
|
|
}}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|