Merge pull request #62 from jhbruhn/copilot/create-shared-infocard-component

Create shared InfoCard component for error/warning/success states
This commit is contained in:
Jan-Henrik Bruhn 2025-12-28 09:01:26 +01:00 committed by GitHub
commit 2a5fbb2232
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 277 additions and 154 deletions

View file

@ -1,10 +1,13 @@
import {
ExclamationTriangleIcon,
InformationCircleIcon,
} from "@heroicons/react/24/solid";
import { getErrorDetails } from "../utils/errorCodeHelpers"; import { getErrorDetails } from "../utils/errorCodeHelpers";
import { PopoverContent } from "@/components/ui/popover"; import { PopoverContent } from "@/components/ui/popover";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import {
InfoCard,
InfoCardTitle,
InfoCardDescription,
InfoCardList,
InfoCardListItem,
} from "./InfoCard";
interface ErrorPopoverContentProps { interface ErrorPopoverContentProps {
machineError?: number; machineError?: number;
@ -24,71 +27,50 @@ export function ErrorPopoverContent({
const errorMsg = pyodideError || errorMessage || ""; const errorMsg = pyodideError || errorMessage || "";
const isInfo = isPairingErr || errorDetails?.isInformational; const isInfo = isPairingErr || errorDetails?.isInformational;
const bgColor = isInfo const variant = isInfo ? "info" : "error";
? "bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500"
: "bg-danger-50 dark:bg-danger-900/95 border-danger-600 dark:border-danger-500";
const iconColor = isInfo
? "text-info-600 dark:text-info-400"
: "text-danger-600 dark:text-danger-400";
const textColor = isInfo
? "text-info-900 dark:text-info-200"
: "text-danger-900 dark:text-danger-200";
const descColor = isInfo
? "text-info-800 dark:text-info-300"
: "text-danger-800 dark:text-danger-300";
const listColor = isInfo
? "text-info-700 dark:text-info-300"
: "text-danger-700 dark:text-danger-300";
const Icon = isInfo ? InformationCircleIcon : ExclamationTriangleIcon;
const title = const title =
errorDetails?.title || (isPairingErr ? "Pairing Required" : "Error"); errorDetails?.title || (isPairingErr ? "Pairing Required" : "Error");
return ( return (
<PopoverContent <PopoverContent className="w-[600px] p-0" align="start">
className={cn("w-[600px] border-l-4 p-4 backdrop-blur-sm", bgColor)} <InfoCard variant={variant} className="rounded-lg shadow-none border-0">
align="start" <InfoCardTitle variant={variant}>{title}</InfoCardTitle>
> <InfoCardDescription variant={variant}>
<div className="flex items-start gap-3">
<Icon className={cn("w-6 h-6 flex-shrink-0 mt-0.5", iconColor)} />
<div className="flex-1">
<h3 className={cn("text-base font-semibold mb-2", textColor)}>
{title}
</h3>
<p className={cn("text-sm mb-3", descColor)}>
{errorDetails?.description || errorMsg} {errorDetails?.description || errorMsg}
</p> </InfoCardDescription>
{errorDetails?.solutions && errorDetails.solutions.length > 0 && ( {errorDetails?.solutions && errorDetails.solutions.length > 0 && (
<> <>
<h4 className={cn("text-sm font-semibold mb-2", textColor)}> <h4
{isInfo ? "Steps:" : "How to Fix:"}
</h4>
<ol
className={cn( className={cn(
"list-decimal list-inside text-sm space-y-1.5", "text-sm font-semibold mb-2",
listColor, variant === "info"
? "text-info-900 dark:text-info-200"
: "text-danger-900 dark:text-danger-200",
)} )}
> >
{isInfo ? "Steps:" : "How to Fix:"}
</h4>
<InfoCardList variant={variant} ordered>
{errorDetails.solutions.map((solution, index) => ( {errorDetails.solutions.map((solution, index) => (
<li key={index} className="pl-2"> <InfoCardListItem key={index}>{solution}</InfoCardListItem>
{solution}
</li>
))} ))}
</ol> </InfoCardList>
</> </>
)} )}
{machineError !== undefined && !errorDetails?.isInformational && ( {machineError !== undefined && !errorDetails?.isInformational && (
<p className={cn("text-xs mt-3 font-mono", descColor)}> <p
className={cn(
"text-xs mt-3 font-mono",
variant === "info"
? "text-info-800 dark:text-info-300"
: "text-danger-800 dark:text-danger-300",
)}
>
Error Code: 0x Error Code: 0x
{machineError.toString(16).toUpperCase().padStart(2, "0")} {machineError.toString(16).toUpperCase().padStart(2, "0")}
</p> </p>
)} )}
</div> </InfoCard>
</div>
</PopoverContent> </PopoverContent>
); );
} }

191
src/components/InfoCard.tsx Normal file
View file

@ -0,0 +1,191 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import {
InformationCircleIcon,
ExclamationTriangleIcon,
CheckCircleIcon,
} from "@heroicons/react/24/solid";
import { cn } from "@/lib/utils";
const infoCardVariants = cva("border-l-4 p-4 rounded-lg backdrop-blur-sm", {
variants: {
variant: {
info: "bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500",
warning:
"bg-warning-50 dark:bg-warning-900/95 border-warning-600 dark:border-warning-500",
error:
"bg-danger-50 dark:bg-danger-900/95 border-danger-600 dark:border-danger-500",
success:
"bg-success-50 dark:bg-success-900/95 border-success-600 dark:border-success-500",
},
},
defaultVariants: {
variant: "info",
},
});
const iconColorVariants = cva("w-6 h-6 flex-shrink-0 mt-0.5", {
variants: {
variant: {
info: "text-info-600 dark:text-info-400",
warning: "text-warning-600 dark:text-warning-400",
error: "text-danger-600 dark:text-danger-400",
success: "text-success-600 dark:text-success-400",
},
},
defaultVariants: {
variant: "info",
},
});
const titleColorVariants = cva("text-base font-semibold mb-2", {
variants: {
variant: {
info: "text-info-900 dark:text-info-200",
warning: "text-warning-900 dark:text-warning-200",
error: "text-danger-900 dark:text-danger-200",
success: "text-success-900 dark:text-success-200",
},
},
defaultVariants: {
variant: "info",
},
});
const descriptionColorVariants = cva("text-sm mb-3", {
variants: {
variant: {
info: "text-info-800 dark:text-info-300",
warning: "text-warning-800 dark:text-warning-300",
error: "text-danger-800 dark:text-danger-300",
success: "text-success-800 dark:text-success-300",
},
},
defaultVariants: {
variant: "info",
},
});
const listColorVariants = cva("text-sm", {
variants: {
variant: {
info: "text-info-700 dark:text-info-300",
warning: "text-warning-700 dark:text-warning-300",
error: "text-danger-700 dark:text-danger-300",
success: "text-success-700 dark:text-success-300",
},
},
defaultVariants: {
variant: "info",
},
});
interface InfoCardProps
extends
React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof infoCardVariants> {
icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
showDefaultIcon?: boolean;
}
function InfoCard({
className,
variant = "info",
icon: CustomIcon,
showDefaultIcon = true,
children,
...props
}: InfoCardProps) {
// Default icons based on variant
const defaultIcons = {
info: InformationCircleIcon,
warning: ExclamationTriangleIcon,
error: ExclamationTriangleIcon,
success: CheckCircleIcon,
} as const;
const Icon =
CustomIcon || (showDefaultIcon && variant ? defaultIcons[variant] : null);
return (
<div className={cn(infoCardVariants({ variant }), className)} {...props}>
<div className="flex items-start gap-3">
{Icon && <Icon className={iconColorVariants({ variant })} />}
<div className="flex-1">{children}</div>
</div>
</div>
);
}
function InfoCardTitle({
className,
variant = "info",
...props
}: React.HTMLAttributes<HTMLHeadingElement> &
VariantProps<typeof titleColorVariants>) {
return (
<h3 className={cn(titleColorVariants({ variant }), className)} {...props} />
);
}
function InfoCardDescription({
className,
variant = "info",
...props
}: React.HTMLAttributes<HTMLParagraphElement> &
VariantProps<typeof descriptionColorVariants>) {
return (
<p
className={cn(descriptionColorVariants({ variant }), className)}
{...props}
/>
);
}
interface InfoCardListProps
extends
React.HTMLAttributes<HTMLOListElement | HTMLUListElement>,
VariantProps<typeof listColorVariants> {
ordered?: boolean;
}
function InfoCardList({
className,
variant = "info",
ordered = false,
children,
...props
}: InfoCardListProps) {
const ListComponent = ordered ? "ol" : "ul";
const listClass = ordered ? "list-decimal" : "list-disc";
return (
<ListComponent
className={cn(
listClass,
"list-inside space-y-1.5",
listColorVariants({ variant }),
className,
)}
{...props}
>
{children}
</ListComponent>
);
}
function InfoCardListItem({
className,
...props
}: React.HTMLAttributes<HTMLLIElement>) {
return <li className={cn("pl-2", className)} {...props} />;
}
export {
InfoCard,
InfoCardTitle,
InfoCardDescription,
InfoCardList,
InfoCardListItem,
};

View file

@ -5,12 +5,15 @@
*/ */
import { forwardRef } from "react"; import { forwardRef } from "react";
import {
InformationCircleIcon,
ExclamationTriangleIcon,
} from "@heroicons/react/24/solid";
import { MachineStatus } from "../../types/machine"; import { MachineStatus } from "../../types/machine";
import { getGuideContent } from "../../utils/workflowGuideContent"; import { getGuideContent } from "../../utils/workflowGuideContent";
import {
InfoCard,
InfoCardTitle,
InfoCardDescription,
InfoCardList,
InfoCardListItem,
} from "../InfoCard";
export interface StepPopoverProps { export interface StepPopoverProps {
stepId: number; stepId: number;
@ -22,54 +25,16 @@ export const StepPopover = forwardRef<HTMLDivElement, StepPopoverProps>(
const content = getGuideContent(stepId, machineStatus); const content = getGuideContent(stepId, machineStatus);
if (!content) return null; if (!content) return null;
const colorClasses = { // Map content type to InfoCard variant
info: "bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500", const variantMap = {
success: info: "info",
"bg-success-50 dark:bg-success-900/95 border-success-600 dark:border-success-500", success: "success",
warning: warning: "warning",
"bg-warning-50 dark:bg-warning-900/95 border-warning-600 dark:border-warning-500", error: "error",
error: progress: "info",
"bg-danger-50 dark:bg-danger-900/95 border-danger-600 dark:border-danger-500", } as const;
progress:
"bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500",
};
const iconColorClasses = { const variant = variantMap[content.type];
info: "text-info-600 dark:text-info-400",
success: "text-success-600 dark:text-success-400",
warning: "text-warning-600 dark:text-warning-400",
error: "text-danger-600 dark:text-danger-400",
progress: "text-info-600 dark:text-info-400",
};
const textColorClasses = {
info: "text-info-900 dark:text-info-200",
success: "text-success-900 dark:text-success-200",
warning: "text-warning-900 dark:text-warning-200",
error: "text-danger-900 dark:text-danger-200",
progress: "text-info-900 dark:text-info-200",
};
const descColorClasses = {
info: "text-info-800 dark:text-info-300",
success: "text-success-800 dark:text-success-300",
warning: "text-warning-800 dark:text-warning-300",
error: "text-danger-800 dark:text-danger-300",
progress: "text-info-800 dark:text-info-300",
};
const listColorClasses = {
info: "text-blue-700 dark:text-blue-300",
success: "text-green-700 dark:text-green-300",
warning: "text-yellow-700 dark:text-yellow-300",
error: "text-red-700 dark:text-red-300",
progress: "text-cyan-700 dark:text-cyan-300",
};
const Icon =
content.type === "warning"
? ExclamationTriangleIcon
: InformationCircleIcon;
return ( return (
<div <div
@ -78,45 +43,30 @@ export const StepPopover = forwardRef<HTMLDivElement, StepPopoverProps>(
role="dialog" role="dialog"
aria-label="Step guidance" aria-label="Step guidance"
> >
<div <InfoCard variant={variant} className="shadow-xl">
className={`${colorClasses[content.type]} border-l-4 p-4 rounded-lg shadow-xl backdrop-blur-sm`} <InfoCardTitle variant={variant}>{content.title}</InfoCardTitle>
> <InfoCardDescription variant={variant}>
<div className="flex items-start gap-3">
<Icon
className={`w-6 h-6 ${iconColorClasses[content.type]} flex-shrink-0 mt-0.5`}
/>
<div className="flex-1">
<h3
className={`text-base font-semibold ${textColorClasses[content.type]} mb-2`}
>
{content.title}
</h3>
<p className={`text-sm ${descColorClasses[content.type]} mb-3`}>
{content.description} {content.description}
</p> </InfoCardDescription>
{content.items && content.items.length > 0 && ( {content.items && content.items.length > 0 && (
<ul <InfoCardList variant={variant}>
className={`list-disc list-inside text-sm ${listColorClasses[content.type]} space-y-1`}
>
{content.items.map((item, index) => { {content.items.map((item, index) => {
// Parse **text** markdown syntax into React elements safely // Parse **text** markdown syntax into React elements safely
const parts = item.split(/(\*\*.*?\*\*)/); const parts = item.split(/(\*\*.*?\*\*)/);
return ( return (
<li key={index} className="pl-2"> <InfoCardListItem key={index}>
{parts.map((part, i) => { {parts.map((part, i) => {
if (part.startsWith("**") && part.endsWith("**")) { if (part.startsWith("**") && part.endsWith("**")) {
return <strong key={i}>{part.slice(2, -2)}</strong>; return <strong key={i}>{part.slice(2, -2)}</strong>;
} }
return part; return part;
})} })}
</li> </InfoCardListItem>
); );
})} })}
</ul> </InfoCardList>
)} )}
</div> </InfoCard>
</div>
</div>
</div> </div>
); );
}, },