| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import React from "react"; |
| import { Check, Loader2, Sparkles } from "lucide-react"; |
|
|
| export interface GeneratingStep { |
| label: string; |
| } |
|
|
| export interface GeneratingPanelProps { |
| |
| title: string; |
| |
| description?: React.ReactNode; |
| |
| variant?: "overlay" | "panel"; |
| |
| steps?: GeneratingStep[]; |
| |
| |
| activeStep?: number; |
| |
| |
| progress?: number; |
| |
| progressLabel?: React.ReactNode; |
| |
| |
| |
| accentClassName?: string; |
| |
| |
| spinnerSize?: "small" | "large"; |
| |
| |
| |
| |
| iconOverride?: React.ReactNode; |
| |
| |
| footerHint?: React.ReactNode | null; |
| } |
|
|
| export function GeneratingPanel({ |
| title, |
| description, |
| variant = "overlay", |
| steps, |
| activeStep = 0, |
| progress, |
| progressLabel, |
| accentClassName = "text-purple-400", |
| spinnerSize = "small", |
| iconOverride, |
| footerHint, |
| }: GeneratingPanelProps) { |
| const clamped = typeof progress === "number" |
| ? Math.max(0, Math.min(100, progress)) |
| : undefined; |
| const hasProgress = clamped !== undefined; |
|
|
| const spinner = spinnerSize === "large" |
| ? <LargeSpinner accentClassName={accentClassName} icon={iconOverride} /> |
| : <SmallSpinner accentClassName={accentClassName} icon={iconOverride} />; |
|
|
| const body = ( |
| <div className="flex flex-col items-center justify-center max-w-md text-center px-6"> |
| {spinner} |
| |
| <div className="text-white/95 text-sm font-semibold">{title}</div> |
| {description && ( |
| <div className="text-white/60 text-xs mt-1.5 leading-relaxed"> |
| {description} |
| </div> |
| )} |
| |
| {hasProgress && ( |
| <div className="mt-4 w-full max-w-xs"> |
| <div |
| className="h-2 bg-white/10 rounded-full overflow-hidden" |
| role="progressbar" |
| aria-valuenow={clamped} |
| aria-valuemin={0} |
| aria-valuemax={100} |
| aria-label={typeof progressLabel === "string" ? progressLabel : "Progress"} |
| > |
| <div |
| className="h-full bg-gradient-to-r from-[#3ea6ff] to-[#6366f1] transition-[width] duration-500 ease-out" |
| style={{ width: `${clamped}%` }} |
| /> |
| </div> |
| {progressLabel && ( |
| <div className="text-[11px] text-white/50 mt-2 tabular-nums"> |
| {progressLabel} |
| </div> |
| )} |
| </div> |
| )} |
| |
| {steps && steps.length > 0 && ( |
| <ol |
| className={[ |
| "w-full max-w-xs text-left flex flex-col gap-2", |
| hasProgress ? "mt-4" : "mt-5", |
| ].join(" ")} |
| aria-label="Progress" |
| > |
| {steps.map((s, i) => { |
| const done = i < activeStep; |
| const running = i === activeStep; |
| return ( |
| <li |
| key={`${i}-${s.label}`} |
| className={[ |
| "flex items-center gap-2.5 text-xs", |
| done ? "text-white/90" |
| : running ? "text-white/95" |
| : "text-white/40", |
| ].join(" ")} |
| > |
| <StepIndicator |
| state={done ? "done" : running ? "running" : "pending"} |
| accentClassName={accentClassName} |
| /> |
| <span>{s.label}</span> |
| </li> |
| ); |
| })} |
| </ol> |
| )} |
| |
| {footerHint !== null && ( |
| <div className="mt-5 px-3 py-1 rounded-full bg-black/60 border border-white/10 text-white/70 text-[11px]"> |
| {footerHint ?? "AI is working β this usually takes a few seconds."} |
| </div> |
| )} |
| </div> |
| ); |
|
|
| if (variant === "overlay") { |
| return ( |
| <div |
| role="status" |
| aria-live="polite" |
| className="absolute inset-0 z-20 flex items-center justify-center bg-black/70 backdrop-blur-sm" |
| > |
| {body} |
| </div> |
| ); |
| } |
| return ( |
| <div |
| role="status" |
| aria-live="polite" |
| className="rounded-2xl border border-[#3f3f3f] bg-[#121212] px-6 py-12 flex items-center justify-center" |
| > |
| {body} |
| </div> |
| ); |
| } |
|
|
|
|
| |
|
|
| function SmallSpinner({ |
| accentClassName, icon, |
| }: { accentClassName: string; icon?: React.ReactNode }) { |
| return ( |
| <div className="flex items-center justify-center gap-2 mb-4"> |
| <Loader2 size={32} className={`${accentClassName} animate-spin`} aria-hidden /> |
| <span className={`${accentClassName} opacity-80`} aria-hidden> |
| {icon ?? <Sparkles size={18} />} |
| </span> |
| </div> |
| ); |
| } |
|
|
|
|
| function LargeSpinner({ |
| accentClassName, icon, |
| }: { accentClassName: string; icon?: React.ReactNode }) { |
| |
| |
| |
| |
| return ( |
| <div className="relative w-20 h-20 mb-5" aria-hidden> |
| {/* Static base ring β signals "work in progress" even when |
| the animated layer is occluded. */} |
| <div className={`absolute inset-0 rounded-full border-4 ${accentToBorder(accentClassName, "/20")}`} /> |
| {/* Rotating arc. Using the built-in animate-spin on a partial |
| border rather than a keyframe gradient so the look stays |
| crisp at any screen DPR. */} |
| <div |
| className={`absolute inset-0 rounded-full border-4 border-transparent ${accentToBorder(accentClassName, "")} animate-spin`} |
| style={{ |
| // Show only the top-right quarter of the ring so it |
| // reads as a clear moving arc instead of a fuzzy halo. |
| borderRightColor: "transparent", |
| borderBottomColor: "transparent", |
| borderLeftColor: "transparent", |
| }} |
| /> |
| {/* Center phase icon. */} |
| <div className={`absolute inset-0 flex items-center justify-center ${accentClassName}`}> |
| {icon ?? <Sparkles size={26} />} |
| </div> |
| </div> |
| ); |
| } |
|
|
|
|
| |
| |
| |
| |
| |
| |
| function accentToBorder(textClass: string, opacitySuffix: string): string { |
| |
| |
| |
| |
| const colorPart = textClass.replace(/^text-/, ""); |
| if (!colorPart) return `border-white${opacitySuffix || ""}`; |
| return `border-${colorPart}${opacitySuffix}`; |
| } |
|
|
|
|
| function StepIndicator({ |
| state, accentClassName, |
| }: { |
| state: "pending" | "running" | "done"; |
| accentClassName: string; |
| }) { |
| if (state === "done") { |
| return ( |
| <span className="w-4 h-4 rounded-full bg-emerald-500/20 border border-emerald-500/40 flex items-center justify-center shrink-0"> |
| <Check className="w-2.5 h-2.5 text-emerald-400" aria-hidden /> |
| </span> |
| ); |
| } |
| if (state === "running") { |
| return ( |
| <span className="w-4 h-4 rounded-full border border-white/20 flex items-center justify-center shrink-0"> |
| <Loader2 className={`w-2.5 h-2.5 ${accentClassName} animate-spin`} aria-hidden /> |
| </span> |
| ); |
| } |
| return ( |
| <span className="w-4 h-4 rounded-full border border-white/15 shrink-0" aria-hidden /> |
| ); |
| } |
|
|