Files
stack/apps/web/src/components/widgets/BaseWidget.tsx
Jason Woltje cc56f2cbe1
All checks were successful
ci/woodpecker/push/web Pipeline was successful
feat(web): migrate dashboard to WidgetGrid with layout persistence (#497)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-24 00:50:24 +00:00

128 lines
3.6 KiB
TypeScript

/**
* BaseWidget - Wrapper component for all widgets
* Provides consistent styling, controls, and error/loading states
*/
import type { ReactNode } from "react";
import { Settings, X } from "lucide-react";
// Simple classnames utility
function cn(...classes: (string | undefined | null | false)[]): string {
return classes.filter(Boolean).join(" ");
}
export interface BaseWidgetProps {
id: string;
title: string;
description?: string;
children: ReactNode;
onEdit?: () => void;
onRemove?: () => void;
className?: string;
isLoading?: boolean;
error?: string;
}
export function BaseWidget({
id,
title,
description,
children,
onEdit,
onRemove,
className,
isLoading = false,
error,
}: BaseWidgetProps): React.JSX.Element {
return (
<div
data-widget-id={id}
className={cn("flex flex-col h-full overflow-hidden", className)}
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: "var(--r-lg)",
boxShadow: "var(--shadow-sm)",
}}
>
{/* Widget Header */}
<div
className="flex items-center justify-between px-4 py-3"
style={{
borderBottom: "1px solid var(--border)",
background: "var(--surface-2)",
}}
>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold truncate" style={{ color: "var(--text)" }}>
{title}
</h3>
{description && (
<p className="text-xs truncate mt-0.5" style={{ color: "var(--muted)" }}>
{description}
</p>
)}
</div>
{/* Control buttons - only show if handlers provided */}
{(onEdit ?? onRemove) && (
<div className="flex items-center gap-1 ml-2">
{onEdit && (
<button
onClick={onEdit}
aria-label="Edit widget"
className="p-1 rounded transition-colors"
style={{ color: "var(--muted)" }}
title="Edit widget"
>
<Settings className="w-4 h-4" />
</button>
)}
{onRemove && (
<button
onClick={onRemove}
aria-label="Remove widget"
className="p-1 rounded transition-colors"
style={{ color: "var(--muted)" }}
title="Remove widget"
>
<X className="w-4 h-4" />
</button>
)}
</div>
)}
</div>
{/* Widget Content */}
<div className="flex-1 p-4 overflow-auto">
{isLoading ? (
<div className="flex items-center justify-center h-full">
<div className="flex flex-col items-center gap-2">
<div
className="w-8 h-8 border-2 border-t-transparent rounded-full animate-spin"
style={{ borderColor: "var(--primary)", borderTopColor: "transparent" }}
/>
<span className="text-sm" style={{ color: "var(--muted)" }}>
Loading...
</span>
</div>
</div>
) : error ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="text-sm font-medium mb-1" style={{ color: "var(--danger)" }}>
Error
</div>
<div className="text-xs" style={{ color: "var(--muted)" }}>
{error}
</div>
</div>
</div>
) : (
children
)}
</div>
</div>
);
}