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>
128 lines
3.6 KiB
TypeScript
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>
|
|
);
|
|
}
|