mirror of
https://github.com/jhbruhn/respira.git
synced 2026-01-27 10:23:41 +00:00
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:
commit
2a5fbb2232
3 changed files with 277 additions and 154 deletions
|
|
@ -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
191
src/components/InfoCard.tsx
Normal 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,
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue