DocDoeAI / src /components /student /StudentWorkspaceUI.tsx
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
16.4 kB
"use client";
import Link from "next/link";
import type { ElementType, FormEvent, ReactNode } from "react";
import {
AlertCircle,
ArrowRight,
Check,
ChevronRight,
LoaderCircle,
Search,
Sparkles,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { normalizeApiError } from "@/lib/api/client";
type Tone = "amber" | "emerald" | "blue" | "violet" | "neutral";
const toneStyles: Record<
Tone,
{
icon: string;
border: string;
glow: string;
text: string;
}
> = {
amber: {
icon: "border-amber-300/15 bg-amber-300/[0.08] text-amber-200",
border: "border-amber-300/12 hover:border-amber-200/30",
glow: "before:bg-amber-400/[0.08]",
text: "text-amber-100",
},
emerald: {
icon: "border-emerald-300/15 bg-emerald-300/[0.08] text-emerald-200",
border: "border-emerald-300/12 hover:border-emerald-200/30",
glow: "before:bg-emerald-400/[0.08]",
text: "text-emerald-100",
},
blue: {
icon: "border-blue-300/15 bg-blue-300/[0.08] text-blue-200",
border: "border-blue-300/12 hover:border-blue-200/30",
glow: "before:bg-blue-400/[0.08]",
text: "text-blue-100",
},
violet: {
icon: "border-fuchsia-200/15 bg-fuchsia-200/[0.06] text-fuchsia-100",
border: "border-fuchsia-200/10 hover:border-fuchsia-100/24",
glow: "before:bg-fuchsia-300/[0.06]",
text: "text-fuchsia-100",
},
neutral: {
icon: "border-white/[0.08] bg-white/[0.04] text-white/78",
border: "border-white/[0.08] hover:border-white/[0.16]",
glow: "before:bg-white/[0.05]",
text: "text-white",
},
};
const panelVariants = {
compact: "rounded-2xl p-4 sm:p-5",
standard: "rounded-[1.35rem] p-5 sm:p-6",
hero: "rounded-[1.6rem] p-6 sm:p-8",
};
function cleanStudentError(error: unknown) {
const normalized = normalizeApiError(error);
const lower = normalized.toLowerCase();
const unsafeTerms = [
"backend",
"chunk",
"token",
"retrieval",
"mock",
"raw json",
"debug",
"server error",
"api failed",
"vector db",
"embedding",
"remotion",
"provider",
"model",
"stack trace",
"file path",
"extraction_error",
"extracted_text",
"document path",
"private filename",
];
if (unsafeTerms.some((term) => lower.includes(term))) {
return "DocDoe could not prepare this right now. Please try again.";
}
if (/[{}[\]\\]/.test(normalized) || normalized.length > 180) {
return "DocDoe could not prepare this right now. Please try again.";
}
return normalized || "DocDoe could not prepare this right now. Please try again.";
}
export interface StudentPageShellProps {
eyebrow?: string;
title?: string;
description?: string;
action?: ReactNode;
children: ReactNode;
className?: string;
contentClassName?: string;
variant?: "standard" | "dense" | "wide" | "centered";
}
export function StudentPageShell({
eyebrow,
title,
description,
action,
children,
className,
contentClassName,
variant = "standard",
}: StudentPageShellProps) {
const widths = {
dense: "max-w-5xl",
standard: "max-w-6xl",
wide: "max-w-7xl",
centered: "max-w-4xl",
};
return (
<section
className={cn(
"relative mx-auto w-full px-4 py-6 sm:px-6 lg:px-8",
variant === "centered" && "min-h-[calc(100vh-8rem)]",
className,
)}
>
<div className={cn("relative mx-auto w-full", widths[variant], contentClassName)}>
{(eyebrow || title || description || action) && (
<div className="mb-5 flex flex-col gap-4 sm:mb-7 lg:flex-row lg:items-end lg:justify-between">
<div className="min-w-0 space-y-2">
{eyebrow && (
<p className="text-[11px] font-semibold uppercase text-white/46">
{eyebrow}
</p>
)}
{title && (
<h1 className="max-w-3xl text-balance text-2xl font-semibold text-white sm:text-[2rem]">
{title}
</h1>
)}
{description && (
<p className="max-w-2xl text-sm leading-6 text-white/62">
{description}
</p>
)}
</div>
{action && <div className="shrink-0">{action}</div>}
</div>
)}
{children}
</div>
</section>
);
}
export interface PremiumPanelProps {
children: ReactNode;
className?: string;
variant?: keyof typeof panelVariants;
tone?: Tone;
interactive?: boolean;
as?: "div" | "section" | "article";
}
export function PremiumPanel({
children,
className,
variant = "standard",
tone = "neutral",
interactive = false,
as: Component = "div",
}: PremiumPanelProps) {
return (
<Component
className={cn(
"relative overflow-hidden border bg-[linear-gradient(145deg,rgba(255,255,255,0.055),rgba(255,255,255,0.018))] shadow-[0_18px_60px_rgba(0,0,0,0.34),inset_0_1px_0_rgba(255,255,255,0.045)] backdrop-blur-xl",
"before:pointer-events-none before:absolute before:-right-20 before:-top-20 before:size-48 before:rounded-full before:blur-3xl",
panelVariants[variant],
toneStyles[tone].border,
toneStyles[tone].glow,
interactive && "transition duration-200 hover:-translate-y-0.5 focus-within:border-white/20",
className,
)}
>
<div className="relative">{children}</div>
</Component>
);
}
export interface ActionTileProps {
title: string;
description: string;
icon: ElementType;
href?: string;
onClick?: () => void;
cta?: string;
tone?: Tone;
disabled?: boolean;
className?: string;
}
export function ActionTile({
title,
description,
icon: Icon,
href,
onClick,
cta = "Open",
tone = "neutral",
disabled = false,
className,
}: ActionTileProps) {
const content = (
<>
<div className="flex items-start gap-3.5">
<span
className={cn(
"grid size-10 shrink-0 place-items-center rounded-xl border",
toneStyles[tone].icon,
)}
>
<Icon className="size-5" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-semibold text-white">{title}</span>
<span className="mt-1 block text-[12.5px] leading-5 text-white/58">{description}</span>
</span>
</div>
<span className={cn("mt-5 inline-flex items-center gap-1.5 text-xs font-semibold", toneStyles[tone].text)}>
{cta}
<ChevronRight className="size-3.5" />
</span>
</>
);
const classes = cn(
"group block h-full rounded-2xl border bg-white/[0.025] p-4 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.035)] transition duration-200 hover:-translate-y-0.5 hover:bg-white/[0.04] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/22",
toneStyles[tone].border,
disabled && "pointer-events-none opacity-45",
className,
);
if (href && !disabled) {
return (
<Link href={href} className={classes}>
{content}
</Link>
);
}
if (onClick && !disabled) {
return (
<button type="button" onClick={onClick} className={classes}>
{content}
</button>
);
}
return <div className={classes}>{content}</div>;
}
export interface EmptyWorkspaceProps {
icon?: ElementType;
title: string;
description: string;
examples?: string[];
actionText?: string;
actionHref?: string;
onAction?: () => void;
className?: string;
}
export function EmptyWorkspace({
icon: Icon = Sparkles,
title,
description,
examples = [],
actionText,
actionHref,
onAction,
className,
}: EmptyWorkspaceProps) {
const action = actionText ? (
actionHref ? (
<Link
href={actionHref}
className="inline-flex items-center justify-center gap-2 rounded-xl border border-white/[0.12] bg-white/[0.055] px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/[0.09] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/22"
>
{actionText}
<ArrowRight className="size-4" />
</Link>
) : (
<button
type="button"
onClick={onAction}
className="inline-flex items-center justify-center gap-2 rounded-xl border border-white/[0.12] bg-white/[0.055] px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/[0.09] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/22"
>
{actionText}
<ArrowRight className="size-4" />
</button>
)
) : null;
return (
<PremiumPanel className={cn("flex min-h-[22rem] items-center justify-center text-center", className)}>
<div className="mx-auto max-w-md">
<div className="mx-auto grid size-12 place-items-center rounded-2xl border border-white/[0.08] bg-white/[0.04] text-white/70">
<Icon className="size-5" />
</div>
<h2 className="mt-4 text-lg font-semibold text-white">{title}</h2>
<p className="mt-2 text-sm leading-6 text-white/58">{description}</p>
{examples.length > 0 && (
<div className="mt-5 flex flex-wrap justify-center gap-2">
{examples.map((example) => (
<span
key={example}
className="rounded-full border border-white/[0.08] bg-white/[0.035] px-3 py-1.5 text-xs text-white/58"
>
{example}
</span>
))}
</div>
)}
{action && <div className="mt-6">{action}</div>}
</div>
</PremiumPanel>
);
}
export interface StudyCommandBarProps {
value: string;
onChange: (value: string) => void;
onSubmit: (value: string) => void;
placeholder?: string;
submitLabel?: string;
chips?: string[];
className?: string;
disabled?: boolean;
}
export function StudyCommandBar({
value,
onChange,
onSubmit,
placeholder = "Search your studies",
submitLabel = "Start",
chips = [],
className,
disabled = false,
}: StudyCommandBarProps) {
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const trimmed = value.trim();
if (trimmed && !disabled) onSubmit(trimmed);
}
return (
<PremiumPanel variant="compact" className={cn("space-y-3", className)} interactive>
<form onSubmit={handleSubmit} className="flex flex-col gap-3 sm:flex-row">
<label className="relative min-w-0 flex-1">
<span className="sr-only">Study request</span>
<Search className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-white/36" />
<input
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
disabled={disabled}
className="h-12 w-full rounded-xl border border-white/[0.08] bg-black/28 px-10 text-sm text-white outline-none transition placeholder:text-white/32 focus:border-white/22 focus:bg-black/38"
/>
</label>
<button
type="submit"
disabled={disabled || !value.trim()}
className="inline-flex h-12 items-center justify-center gap-2 rounded-xl bg-white px-5 text-sm font-semibold text-black transition hover:bg-white/88 disabled:cursor-not-allowed disabled:opacity-45"
>
{submitLabel}
<ArrowRight className="size-4" />
</button>
</form>
{chips.length > 0 && (
<div className="flex flex-wrap gap-2">
{chips.map((chip) => (
<button
key={chip}
type="button"
onClick={() => onChange(chip)}
className="rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1.5 text-xs text-white/62 transition hover:border-white/[0.16] hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white/22"
>
{chip}
</button>
))}
</div>
)}
</PremiumPanel>
);
}
export interface SafeErrorBannerProps {
error: unknown;
title?: string;
className?: string;
}
export function SafeErrorBanner({ error, title = "We could not finish that", className }: SafeErrorBannerProps) {
return (
<div
role="alert"
className={cn(
"rounded-2xl border border-rose-300/18 bg-rose-300/[0.055] p-4 text-sm text-rose-50 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]",
className,
)}
>
<div className="flex gap-3">
<AlertCircle className="mt-0.5 size-4 shrink-0 text-rose-200" />
<div className="min-w-0">
<p className="font-semibold">{title}</p>
<p className="mt-1 leading-5 text-rose-50/72">{cleanStudentError(error)}</p>
</div>
</div>
</div>
);
}
export interface GeneratorWorkspaceProps {
title: string;
description: string;
setup: ReactNode;
children?: ReactNode;
empty?: ReactNode;
loading?: boolean;
loadingLabel?: string;
error?: unknown;
footer?: ReactNode;
className?: string;
}
export function GeneratorWorkspace({
title,
description,
setup,
children,
empty,
loading = false,
loadingLabel = "Preparing your study output",
error,
footer,
className,
}: GeneratorWorkspaceProps) {
return (
<div className={cn("grid gap-5 lg:grid-cols-[minmax(17rem,0.82fr)_minmax(0,1.5fr)]", className)}>
<PremiumPanel className="h-fit" variant="standard">
<div className="mb-5">
<p className="text-[11px] font-semibold uppercase text-white/42">Setup</p>
<h2 className="mt-1 text-lg font-semibold text-white">{title}</h2>
<p className="mt-2 text-sm leading-6 text-white/56">{description}</p>
</div>
{setup}
</PremiumPanel>
<div className="min-w-0 space-y-4">
{error ? <SafeErrorBanner error={error} /> : null}
{loading ? (
<PremiumPanel className="min-h-[24rem]">
<div className="flex min-h-[18rem] flex-col items-center justify-center text-center">
<LoaderCircle className="size-7 animate-spin text-white/68" />
<h3 className="mt-4 text-base font-semibold text-white">{loadingLabel}</h3>
<p className="mt-2 max-w-sm text-sm leading-6 text-white/54">
Keep this open while DocDoe prepares the result.
</p>
</div>
</PremiumPanel>
) : (
children ?? empty
)}
{footer}
</div>
</div>
);
}
export interface StatusTimelineStep {
id: string;
label: string;
description?: string;
status?: "complete" | "current" | "waiting" | "error";
}
export interface StatusTimelineProps {
steps: StatusTimelineStep[];
className?: string;
}
export function StatusTimeline({ steps, className }: StatusTimelineProps) {
return (
<div className={cn("space-y-3", className)}>
{steps.map((step) => {
const status = step.status ?? "waiting";
return (
<div key={step.id} className="flex gap-3">
<span
className={cn(
"mt-0.5 grid size-7 shrink-0 place-items-center rounded-full border",
status === "complete" && "border-emerald-300/25 bg-emerald-300/[0.08] text-emerald-100",
status === "current" && "border-white/20 bg-white/[0.08] text-white",
status === "waiting" && "border-white/[0.08] bg-white/[0.025] text-white/34",
status === "error" && "border-rose-300/22 bg-rose-300/[0.08] text-rose-100",
)}
>
{status === "complete" ? (
<Check className="size-3.5" />
) : status === "current" ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : status === "error" ? (
<AlertCircle className="size-3.5" />
) : (
<span className="size-1.5 rounded-full bg-current" />
)}
</span>
<div className="min-w-0 pb-1">
<p className="text-sm font-semibold text-white">{step.label}</p>
{step.description && <p className="mt-0.5 text-xs leading-5 text-white/50">{step.description}</p>}
</div>
</div>
);
})}
</div>
);
}