mirror: import production source 2/3

This commit is contained in:
Admin 2026-09-27 16:59:12 +00:00
parent 1b587d88e6
commit 17e3db9970
100 changed files with 18187 additions and 0 deletions

View File

@ -0,0 +1,92 @@
import * as React from "react";
import { cn } from "./utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 pt-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<h4
data-slot="card-title"
className={cn("leading-none", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<p
data-slot="card-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6 [&:last-child]:pb-6", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 pb-6 [.border-t]:pt-6", className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};

View File

@ -0,0 +1,241 @@
"use client";
import * as React from "react";
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { cn } from "./utils";
import { Button } from "./button";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return;
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) return;
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) return;
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel();
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className,
)}
{...props}
/>
</div>
);
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel();
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className,
)}
{...props}
/>
);
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
);
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
);
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};

View File

@ -0,0 +1,353 @@
"use client";
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "./utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
}) {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color,
);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
},
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}) {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className,
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3",
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};

View File

@ -0,0 +1,32 @@
"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import { cn } from "./utils";
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border bg-input-background dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };

View File

@ -0,0 +1,33 @@
"use client";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
);
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
);
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@ -0,0 +1,177 @@
"use client";
import * as React from "react";
import { Command as CommandPrimitive } from "cmdk";
import { SearchIcon } from "lucide-react";
import { cn } from "./utils";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "./dialog";
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className,
)}
{...props}
/>
);
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
);
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className,
)}
{...props}
/>
);
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
);
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className,
)}
{...props}
/>
);
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
);
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@ -0,0 +1,252 @@
"use client";
import * as React from "react";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "./utils";
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
);
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
);
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
);
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
);
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
);
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
);
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
);
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};

View File

@ -0,0 +1,135 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "./utils";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@ -0,0 +1,132 @@
"use client";
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "./utils";
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className,
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
);
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
);
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
);
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};

View File

@ -0,0 +1,257 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "./utils";
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};

View File

@ -0,0 +1,168 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { cn } from "./utils";
import { Label } from "./label";
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue,
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState } = useFormContext();
const formState = useFormState({ name: fieldContext.name });
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue,
);
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
);
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField();
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
);
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField();
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? "") : props.children;
if (!body) {
return null;
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
);
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};

View File

@ -0,0 +1,44 @@
"use client";
import * as React from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "./utils";
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
);
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className,
)}
{...props}
/>
</HoverCardPrimitive.Portal>
);
}
export { HoverCard, HoverCardTrigger, HoverCardContent };

View File

@ -0,0 +1,77 @@
"use client";
import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { MinusIcon } from "lucide-react";
import { cn } from "./utils";
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string;
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"flex items-center gap-2 has-disabled:opacity-50",
containerClassName,
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
);
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn("flex items-center gap-1", className)}
{...props}
/>
);
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number;
}) {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm bg-input-background transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
);
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
);
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View File

@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "./utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base bg-input-background transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
);
}
export { Input };

View File

@ -0,0 +1,24 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "./utils";
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };

View File

@ -0,0 +1,276 @@
"use client";
import * as React from "react";
import * as MenubarPrimitive from "@radix-ui/react-menubar";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "./utils";
function Menubar({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
return (
<MenubarPrimitive.Root
data-slot="menubar"
className={cn(
"bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
className,
)}
{...props}
/>
);
}
function MenubarMenu({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />;
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />;
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />;
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return (
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
);
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
return (
<MenubarPrimitive.Trigger
data-slot="menubar-trigger"
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
className,
)}
{...props}
/>
);
}
function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
return (
<MenubarPortal>
<MenubarPrimitive.Content
data-slot="menubar-content"
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</MenubarPortal>
);
}
function MenubarItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<MenubarPrimitive.Item
data-slot="menubar-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function MenubarCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
return (
<MenubarPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
);
}
function MenubarRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
return (
<MenubarPrimitive.RadioItem
data-slot="menubar-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
);
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
inset?: boolean;
}) {
return (
<MenubarPrimitive.Label
data-slot="menubar-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
return (
<MenubarPrimitive.Separator
data-slot="menubar-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="menubar-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
function MenubarSub({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />;
}
function MenubarSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<MenubarPrimitive.SubTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
);
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
return (
<MenubarPrimitive.SubContent
data-slot="menubar-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
};

View File

@ -0,0 +1,168 @@
import * as React from "react";
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { cva } from "class-variance-authority";
import { ChevronDownIcon } from "lucide-react";
import { cn } from "./utils";
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean;
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className,
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
);
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-1",
className,
)}
{...props}
/>
);
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
);
}
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1",
);
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
);
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
className,
)}
{...props}
/>
);
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center",
)}
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
className,
)}
{...props}
/>
</div>
);
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
className,
)}
{...props}
>
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator>
);
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
};

View File

@ -0,0 +1,127 @@
import * as React from "react";
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react";
import { cn } from "./utils";
import { Button, buttonVariants } from "./button";
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
);
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
);
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />;
}
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">;
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className,
)}
{...props}
/>
);
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
);
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
);
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
);
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
};

View File

@ -0,0 +1,48 @@
"use client";
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "./utils";
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View File

@ -0,0 +1,31 @@
"use client";
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "./utils";
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };

View File

@ -0,0 +1,45 @@
"use client";
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { CircleIcon } from "lucide-react";
import { cn } from "./utils";
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
);
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
}
export { RadioGroup, RadioGroupItem };

View File

@ -0,0 +1,56 @@
"use client";
import * as React from "react";
import { GripVerticalIcon } from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "./utils";
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className,
)}
{...props}
/>
);
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className,
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };

View File

@ -0,0 +1,58 @@
"use client";
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "./utils";
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar };

View File

@ -0,0 +1,189 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import {
CheckIcon,
ChevronDownIcon,
ChevronUpIcon,
} from "lucide-react";
import { cn } from "./utils";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-full items-center justify-between gap-2 rounded-md border bg-input-background px-3 py-2 text-sm whitespace-nowrap transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View File

@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "./utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator-root"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className,
)}
{...props}
/>
);
}
export { Separator };

View File

@ -0,0 +1,139 @@
"use client";
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "./utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className,
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
);
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

View File

@ -0,0 +1,726 @@
"use client";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { VariantProps, cva } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react";
import { useIsMobile } from "./use-mobile";
import { cn } from "./utils";
import { Button } from "./button";
import { Input } from "./input";
import { Separator } from "./separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "./sheet";
import { Skeleton } from "./skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "./tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className,
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className,
)}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
);
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar();
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className,
)}
{...props}
/>
);
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div";
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
);
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
);
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
);
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className,
)}
{...props}
/>
);
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
);
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};

View File

@ -0,0 +1,13 @@
import { cn } from "./utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
);
}
export { Skeleton };

View File

@ -0,0 +1,63 @@
"use client";
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "./utils";
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max],
);
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className,
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-4 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5",
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
);
}
export { Slider };

View File

@ -0,0 +1,25 @@
"use client";
import { useTheme } from "next-themes";
import { Toaster as Sonner, ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
);
};
export { Toaster };

View File

@ -0,0 +1,31 @@
"use client";
import * as React from "react";
import * as SwitchPrimitive from "@radix-ui/react-switch";
import { cn } from "./utils";
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-switch-background focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-card dark:data-[state=unchecked]:bg-card-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitive.Root>
);
}
export { Switch };

View File

@ -0,0 +1,116 @@
"use client";
import * as React from "react";
import { cn } from "./utils";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
);
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
);
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className,
)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className,
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
);
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

View File

@ -0,0 +1,66 @@
"use client";
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "./utils";
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
);
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-xl p-[3px] flex",
className,
)}
{...props}
/>
);
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-card dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-xl border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "./utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"resize-none border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-input-background px-3 py-2 text-base transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
{...props}
/>
);
}
export { Textarea };

View File

@ -0,0 +1,73 @@
"use client";
import * as React from "react";
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import { type VariantProps } from "class-variance-authority";
import { cn } from "./utils";
import { toggleVariants } from "./toggle";
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
});
function ToggleGroup({
className,
variant,
size,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
className={cn(
"group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
className,
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
);
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
className,
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
}
export { ToggleGroup, ToggleGroupItem };

View File

@ -0,0 +1,47 @@
"use client";
import * as React from "react";
import * as TogglePrimitive from "@radix-ui/react-toggle";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "./utils";
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Toggle, toggleVariants };

View File

@ -0,0 +1,61 @@
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "./utils";
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@ -0,0 +1,21 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}

View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@ -0,0 +1,80 @@
export type ConsentCategory = 'necessary' | 'analytics' | 'marketing' | 'preferences'
export type ConsentPreferences = {
necessary: true
analytics: boolean
marketing: boolean
preferences: boolean
decidedAt: string
}
const STORAGE_KEY = 'cms-consent-preferences-v1'
export const DEFAULT_CONSENT: ConsentPreferences = {
necessary: true,
analytics: false,
marketing: false,
preferences: false,
decidedAt: '',
}
export function withDecisionTimestamp(consent: Omit<ConsentPreferences, 'decidedAt'>): ConsentPreferences {
return {
...consent,
necessary: true,
decidedAt: new Date().toISOString(),
}
}
export function acceptAllConsent(): ConsentPreferences {
return withDecisionTimestamp({
necessary: true,
analytics: true,
marketing: true,
preferences: true,
})
}
export function rejectNonEssentialConsent(): ConsentPreferences {
return withDecisionTimestamp({
necessary: true,
analytics: false,
marketing: false,
preferences: false,
})
}
export function readConsentPreferences(storage: Storage = window.localStorage): ConsentPreferences | null {
const raw = storage.getItem(STORAGE_KEY)
if (!raw) {
return null
}
try {
const parsed = JSON.parse(raw) as Partial<ConsentPreferences>
if (typeof parsed.decidedAt !== 'string' || parsed.decidedAt.trim() === '') {
return null
}
return {
necessary: true,
analytics: parsed.analytics === true,
marketing: parsed.marketing === true,
preferences: parsed.preferences === true,
decidedAt: parsed.decidedAt,
}
} catch {
return null
}
}
export function saveConsentPreferences(
consent: ConsentPreferences,
storage: Storage = window.localStorage,
): void {
storage.setItem(STORAGE_KEY, JSON.stringify(consent))
}
export function getConsentStorageKey(): string {
return STORAGE_KEY
}

View File

@ -0,0 +1,61 @@
export type HomeNavItem = {
id: string
label: string
to: string
roles?: string[]
}
const BASE_ITEMS: HomeNavItem[] = [
{ id: 'builder', label: 'Builder', to: '/admin/builder' },
{ id: 'scorm', label: 'SCORM', to: '/admin/scorm', roles: ['admin', 'editor', 'instructor'] },
{ id: 'forms', label: 'Forms', to: '/admin/forms', roles: ['admin'] },
{ id: 'blog', label: 'Blog', to: '/blog' },
{ id: 'live-courses', label: 'Live Courses', to: '/live-courses' },
{ id: 'learn', label: 'Learner', to: '/learn', roles: ['learner', 'admin', 'editor', 'instructor'] },
{ id: 'admin', label: 'Admin', to: '/admin', roles: ['admin'] },
{ id: 'platform-admin', label: 'Platform Admin', to: '/platform-admin', roles: ['admin', 'platform_admin', 'platform_owner'] },
{ id: 'sitemap', label: 'Sitemap', to: '/admin/sitemap', roles: ['admin'] },
{ id: 'themes', label: 'Themes', to: '/admin/keycloak-themes', roles: ['admin'] },
]
function normalizeRoles(roles: string[]): Set<string> {
return new Set(roles.map(role => role.trim().toLowerCase()).filter(Boolean))
}
export function canViewHomeNavItem(item: HomeNavItem, roles: string[]): boolean {
if (!item.roles || item.roles.length === 0) {
return true
}
const active = normalizeRoles(roles)
return item.roles.some(role => active.has(role.toLowerCase()))
}
export function getHomeNavItems(roles: string[]): HomeNavItem[] {
return BASE_ITEMS.filter(item => canViewHomeNavItem(item, roles))
}
export type HomeNavAnalyticsPayload = {
action: 'navigate' | 'logout'
navId: string
to?: string
}
export function trackHomeNavEvent(payload: HomeNavAnalyticsPayload): void {
const analyticsEvent = {
event: 'home_nav_interaction',
...payload,
timestamp: Date.now(),
}
if (typeof window === 'undefined') {
return
}
window.dispatchEvent(new CustomEvent('home-nav:interaction', { detail: analyticsEvent }))
const withDataLayer = window as typeof window & { dataLayer?: unknown[] }
if (Array.isArray(withDataLayer.dataLayer)) {
withDataLayer.dataLayer.push(analyticsEvent)
}
}

View File

@ -0,0 +1,159 @@
export type ThemeFieldType = 'text' | 'textarea' | 'url' | 'number' | 'select' | 'color' | 'image'
export type ThemeFieldSchema = {
type: ThemeFieldType
name: string
label: string
required?: boolean
options?: string[]
}
export type ThemeSectionSchema = {
id: string
title: string
fields: ThemeFieldSchema[]
}
const HEX_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/
const UNSAFE_TOKENS = ['<script', '</script', 'javascript:', 'onerror=', 'onload=', '<iframe', 'srcdoc=', 'data:text/html', 'vbscript:']
export const DEFAULT_THEME_FORM_SCHEMA: ThemeSectionSchema[] = [
{
id: 'brand',
title: 'Brand',
fields: [
{ type: 'image', name: 'logoAssetId', label: 'Logo' },
{ type: 'image', name: 'faviconAssetId', label: 'Favicon' },
{ type: 'text', name: 'logoAssetMimeType', label: 'Logo MIME type' },
{ type: 'text', name: 'faviconAssetMimeType', label: 'Favicon MIME type' },
{ type: 'color', name: 'primaryColor', label: 'Primary colour', required: true },
{ type: 'color', name: 'secondaryColor', label: 'Secondary colour' },
{ type: 'color', name: 'backgroundColor', label: 'Background colour' },
{ type: 'color', name: 'textColor', label: 'Text colour' },
{ type: 'select', name: 'fontFamily', label: 'Font family', options: ['Inter', 'System UI', 'Roboto', 'Open Sans', 'Lato', 'Montserrat'] },
{ type: 'number', name: 'buttonRadius', label: 'Button radius' },
],
},
{
id: 'authCopy',
title: 'Authentication copy',
fields: [
{ type: 'text', name: 'loginHeading', label: 'Login heading' },
{ type: 'textarea', name: 'loginSubheading', label: 'Login subheading' },
{ type: 'textarea', name: 'loginHelpText', label: 'Login help text' },
{ type: 'text', name: 'registrationHeading', label: 'Registration heading' },
{ type: 'textarea', name: 'forgotPasswordHelpText', label: 'Forgot password help' },
],
},
{
id: 'accountAndEmail',
title: 'Account and email templates',
fields: [
{ type: 'text', name: 'accountConsoleHeading', label: 'Account console heading' },
{ type: 'textarea', name: 'emailTemplateFooter', label: 'Email footer text' },
],
},
{
id: 'linksAndLegal',
title: 'Links and legal',
fields: [
{ type: 'url', name: 'supportUrl', label: 'Support URL' },
{ type: 'url', name: 'privacyUrl', label: 'Privacy URL' },
{ type: 'url', name: 'termsUrl', label: 'Terms URL' },
{ type: 'textarea', name: 'footerText', label: 'Footer text' },
{ type: 'textarea', name: 'securityText', label: 'Security text' },
],
},
]
export const DEFAULT_THEME_FORM_VALUES: Record<string, string | number | boolean> = {
primaryColor: '#4f46e5',
backgroundColor: '#ffffff',
textColor: '#0f172a',
fontFamily: 'Inter',
buttonRadius: 8,
loginHeading: 'Welcome back',
registrationHeading: 'Create your account',
footerText: 'Powered by CMS Platform',
securityText: 'Never share your password with anyone.',
}
export type ThemeFormValidationResult = {
valid: boolean
errors: Record<string, string>
}
function hasUnsafeToken(value: string): boolean {
const normalized = value.toLowerCase()
return UNSAFE_TOKENS.some(token => normalized.includes(token))
}
function isValidUrl(value: string): boolean {
try {
const parsed = new URL(value)
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
} catch {
return false
}
}
export function validateThemeFormValues(
schema: ThemeSectionSchema[],
values: Record<string, unknown>,
): ThemeFormValidationResult {
const errors: Record<string, string> = {}
for (const section of schema) {
for (const field of section.fields) {
const value = values[field.name]
const textValue = typeof value === 'string' ? value.trim() : ''
if (field.required && (value === undefined || value === null || textValue === '')) {
errors[field.name] = `${field.label} is required`
continue
}
if (value === undefined || value === null || textValue === '') {
continue
}
if (typeof value === 'string' && hasUnsafeToken(value)) {
errors[field.name] = `${field.label} contains unsafe content`
continue
}
if (field.type === 'color' && typeof value === 'string' && !HEX_COLOR_PATTERN.test(value.trim())) {
errors[field.name] = `${field.label} must be a hex color`
continue
}
if (field.type === 'url' && typeof value === 'string' && !isValidUrl(value.trim())) {
errors[field.name] = `${field.label} must be a valid URL`
continue
}
if (field.type === 'number') {
const numeric = typeof value === 'number' ? value : Number(value)
if (Number.isNaN(numeric)) {
errors[field.name] = `${field.label} must be a number`
continue
}
}
if (field.type === 'select' && field.options && !field.options.includes(String(value).trim())) {
errors[field.name] = `${field.label} has an unsupported value`
continue
}
if (field.type === 'image' && typeof value === 'string' && value.includes(' ')) {
errors[field.name] = `${field.label} must not contain spaces`
}
}
}
return {
valid: Object.keys(errors).length === 0,
errors,
}
}

View File

@ -0,0 +1,82 @@
type ThemeStyle = {
primaryColor: string
backgroundColor: string
textColor: string
buttonRadius: number
fontFamily: string
footerText: string
securityText: string
}
type ThemePreviewCard = {
title: string
subtitle: string
buttonLabel: string
style: ThemeStyle
}
export type ThemePreviewModel = {
themeType: 'login' | 'account' | 'admin' | 'email'
login: ThemePreviewCard
account: ThemePreviewCard
}
const UNSAFE_TOKENS = ['<script', '</script', 'javascript:', 'onerror=', 'onload=', '<iframe', 'srcdoc=', 'data:text/html', 'vbscript:']
function sanitizeString(value: unknown, fallback: string): string {
const candidate = typeof value === 'string' ? value.trim() : ''
if (!candidate) {
return fallback
}
const lowered = candidate.toLowerCase()
if (UNSAFE_TOKENS.some(token => lowered.includes(token))) {
return fallback
}
return candidate
}
function sanitizeColor(value: unknown, fallback: string): string {
const candidate = sanitizeString(value, fallback)
return /^#[0-9a-fA-F]{6}$/.test(candidate) ? candidate : fallback
}
function sanitizeNumber(value: unknown, fallback: number): number {
const numeric = typeof value === 'number' ? value : Number(value)
if (Number.isNaN(numeric)) {
return fallback
}
return Math.min(32, Math.max(0, numeric))
}
export function buildThemePreviewModel(
values: Record<string, unknown>,
themeType: 'login' | 'account' | 'admin' | 'email',
): ThemePreviewModel {
const style: ThemeStyle = {
primaryColor: sanitizeColor(values.primaryColor, '#4f46e5'),
backgroundColor: sanitizeColor(values.backgroundColor, '#ffffff'),
textColor: sanitizeColor(values.textColor, '#0f172a'),
buttonRadius: sanitizeNumber(values.buttonRadius, 8),
fontFamily: sanitizeString(values.fontFamily, 'Inter'),
footerText: sanitizeString(values.footerText, 'Powered by CMS Platform'),
securityText: sanitizeString(values.securityText, 'Never share your password with anyone.'),
}
return {
themeType,
login: {
title: sanitizeString(values.loginHeading, 'Welcome back'),
subtitle: sanitizeString(values.loginSubheading, 'Sign in with your organization account.'),
buttonLabel: 'Sign in',
style,
},
account: {
title: sanitizeString(values.accountConsoleHeading, 'Account settings'),
subtitle: sanitizeString(values.securityText, 'Manage your account securely.'),
buttonLabel: 'Save changes',
style,
},
}
}

View File

@ -0,0 +1,216 @@
import { useEffect, useState } from 'react'
import { Link, Outlet, useLocation, useMatches } from 'react-router'
import {
Bell,
BookOpen,
ChevronLeft,
ChevronRight,
ClipboardList,
CreditCard,
FileText,
FileCode2,
Home,
Image,
LayoutDashboard,
LogOut,
Menu,
MessageCircle,
Newspaper,
Palette,
Settings,
Shield,
Sparkles,
Users,
X,
type LucideIcon,
} from 'lucide-react'
import { currentUser, logout } from '../auth/keycloak'
import { getAdminNavItems, hasAdminShellAccess, isAdminOnlyAdminRoute } from '../admin-nav'
import { AdminPageSurface, type AdminPageSurfaceMode } from '../components/AdminPageSurface'
function getNavIcon(key: string): LucideIcon {
switch (key) {
case 'workspace':
return Home
case 'dashboard':
return LayoutDashboard
case 'builder':
return FileCode2
case 'content':
return FileText
case 'courses':
return BookOpen
case 'blog':
return Newspaper
case 'forms':
return ClipboardList
case 'sitemap':
return Sparkles
case 'themes':
return Palette
case 'learners':
return Users
case 'notifications':
return Bell
case 'assets':
return Image
case 'billing':
return CreditCard
case 'communities':
return MessageCircle
case 'audit':
return Shield
case 'settings':
return Settings
default:
return Home
}
}
export function AdminLayout() {
const location = useLocation()
const matches = useMatches()
const [mobileNavOpen, setMobileNavOpen] = useState(false)
const [desktopNavCompact, setDesktopNavCompact] = useState(() => {
if (typeof window === 'undefined') return false
return window.localStorage.getItem('cms-admin-nav-compact') === 'true'
})
const user = currentUser()
const roles = user.roles ?? []
const navItems = getAdminNavItems(roles)
const surfaceMode = (matches.at(-1)?.handle as { adminSurface?: AdminPageSurfaceMode } | undefined)?.adminSurface ?? 'framed'
const isPlainSurface = surfaceMode === 'plain'
useEffect(() => {
setMobileNavOpen(false)
}, [location.pathname])
useEffect(() => {
const activeItem = navItems.find(item => location.pathname === item.to)
document.title = `${activeItem?.label ?? 'Admin'} | CMS`
}, [location.pathname, navItems])
useEffect(() => {
window.localStorage.setItem('cms-admin-nav-compact', String(desktopNavCompact))
}, [desktopNavCompact])
if (!hasAdminShellAccess(roles)) {
return (
<main className="mx-auto max-w-4xl p-6" data-testid="admin-layout-denied">
<h1 className="text-xl font-semibold text-zinc-900">Admin access required</h1>
<p className="mt-2 text-sm text-zinc-600">
This section is restricted to admin and instructor roles.
</p>
</main>
)
}
if (isAdminOnlyAdminRoute(location.pathname) && !roles.includes('admin')) {
return (
<main className="mx-auto max-w-4xl p-6" data-testid="admin-layout-admin-only-denied">
<h1 className="text-xl font-semibold text-zinc-900">Admin role required</h1>
<p className="mt-2 text-sm text-zinc-600">
This route is reserved for admin users.
</p>
</main>
)
}
return (
<div className="flex h-screen flex-col overflow-hidden bg-[radial-gradient(circle_at_top,_#f4efe4,_#f8f5ef_32%,_#ebe6de)] text-stone-900" data-testid="admin-layout-shell">
<header className={`flex-none ${isPlainSurface ? 'px-4 py-2' : 'px-4 py-3'}`}>
<div className={`mx-auto flex items-center justify-between border border-stone-200/80 bg-white/85 px-4 shadow-[0_18px_50px_rgba(65,48,31,0.08)] backdrop-blur md:px-5 ${isPlainSurface ? 'max-w-none rounded-2xl py-2' : 'max-w-7xl rounded-[26px] py-3'}`}>
<div>
{!isPlainSurface ? <p className="text-xs font-semibold uppercase tracking-[0.28em] text-stone-500">Operations</p> : null}
<h1 className="text-sm font-semibold text-stone-900">Admin Area</h1>
{!isPlainSurface ? <p className="text-xs text-stone-500">Operational control plane and management tools</p> : null}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setMobileNavOpen(open => !open)}
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-stone-200 bg-white text-stone-600 transition hover:bg-stone-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 lg:hidden"
aria-label="Toggle admin navigation"
data-testid="admin-layout-nav-toggle"
>
{mobileNavOpen ? <X size={14} /> : <Menu size={14} />}
</button>
<span className="rounded-full border border-stone-200 bg-stone-50 px-2.5 py-1 text-xs font-medium text-stone-600">
{user.username || user.email || 'admin'}
</span>
<button
type="button"
onClick={() => void logout()}
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-stone-200 bg-white text-stone-600 transition hover:bg-stone-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
aria-label="Log out"
title="Log out"
>
<LogOut size={14} />
</button>
</div>
</div>
</header>
<div className={`relative grid min-h-0 flex-1 gap-4 overflow-hidden pr-4 ${desktopNavCompact ? 'lg:grid-cols-[72px_1fr]' : 'lg:grid-cols-[240px_1fr]'}`}>
<aside className={`${mobileNavOpen ? 'absolute inset-0 z-40 block' : 'hidden'} min-h-0 rounded-r-[28px] border border-l-0 border-stone-200/80 bg-white/95 p-3 shadow-[0_18px_50px_rgba(65,48,31,0.08)] backdrop-blur lg:static lg:z-auto lg:block`}>
<div className="flex h-full min-h-0 flex-col">
<nav className="min-h-0 flex-1 space-y-1 overflow-y-auto" aria-label="Admin navigation">
{navItems.map(item => {
const active = location.pathname === item.to
const Icon = getNavIcon(item.key)
const itemClasses = `flex items-center gap-2 rounded-xl px-3 py-2 text-xs transition-all ${desktopNavCompact ? 'lg:h-10 lg:justify-center lg:gap-0 lg:px-1 lg:py-0' : ''} ${active ? 'bg-[linear-gradient(135deg,#18181b,#3f3f46)] text-white shadow-[0_10px_25px_rgba(24,24,27,0.18)]' : 'text-stone-600 hover:bg-stone-100 hover:text-stone-900'}`
if (item.disabled) {
const disabledClasses = `flex items-center gap-2 rounded-xl px-3 py-2 text-xs text-stone-300 ${desktopNavCompact ? 'lg:h-10 lg:justify-center lg:gap-0 lg:px-1 lg:py-0' : ''}`
return (
<div
key={item.key}
className={disabledClasses}
aria-disabled="true"
title={item.label}
>
<Icon size={15} aria-hidden="true" />
<span className={desktopNavCompact ? 'lg:sr-only' : ''}>{item.label}</span>
{item.description && !desktopNavCompact ? <span className="mt-0.5 block text-[11px]">{item.description}</span> : null}
</div>
)
}
return (
<Link
key={item.key}
to={item.to}
title={item.label}
aria-current={active ? 'page' : undefined}
className={itemClasses}
>
<Icon size={15} aria-hidden="true" />
<span className={desktopNavCompact ? 'lg:sr-only' : ''}>{item.label}</span>
</Link>
)
})}
</nav>
<div className={`mt-3 hidden border-t border-stone-200/80 pt-3 lg:flex ${desktopNavCompact ? 'justify-center' : 'justify-end'}`}>
<button
type="button"
onClick={() => setDesktopNavCompact(compact => !compact)}
className={`inline-flex h-10 items-center rounded-full border border-stone-200 bg-white text-xs font-medium text-stone-600 transition-colors hover:bg-stone-100 ${desktopNavCompact ? 'w-8 justify-center px-0' : 'gap-1.5 px-3'}`}
aria-label="Toggle compact sidebar"
aria-expanded={!desktopNavCompact}
data-testid="admin-layout-desktop-nav-toggle"
title={desktopNavCompact ? 'Expand nav' : 'Compact nav'}
>
{desktopNavCompact ? <ChevronRight size={14} /> : <><ChevronLeft size={14} /><span>Compact nav</span></>}
</button>
</div>
</div>
</aside>
<AdminPageSurface mode={surfaceMode}>
<Outlet />
</AdminPageSurface>
</div>
</div>
)
}

View File

@ -0,0 +1,112 @@
import { NavLink, Outlet } from 'react-router'
import { useEffect } from 'react'
import { currentUser, getAccessToken } from '../auth/keycloak'
import { LEARNER_NAV_ITEMS, hasAnyRole, hasLearnerPortalAccess } from '../learner-portal'
export function LearnerLayout() {
const user = currentUser()
const roles = user.roles ?? []
useEffect(() => {
// Re-enter bootstrap auth flow when the token is missing.
if (!getAccessToken()) {
window.location.reload()
}
}, [])
if (!hasLearnerPortalAccess(roles)) {
return (
<main className="mx-auto max-w-3xl px-4 py-14">
<h1 className="text-3xl font-semibold text-stone-900">Learner Portal Access Restricted</h1>
<p className="mt-2 text-sm text-stone-600">
Your account is authenticated but does not currently have learner portal access.
</p>
</main>
)
}
return (
<main data-testid="learner-layout" className="min-h-screen bg-gradient-to-b from-white via-slate-50 to-sky-50 text-slate-900">
<div className="mx-auto grid max-w-7xl gap-6 px-4 py-8 md:grid-cols-[260px_1fr] md:px-6 md:py-10">
<aside className="h-fit rounded-[24px] border border-stone-200/80 bg-white/90 p-4 shadow-[0_16px_40px_rgba(65,48,31,0.07)] backdrop-blur">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-slate-500">Learner Area</p>
<p className="mt-1 text-sm text-stone-700">{user.username || user.email || 'Authenticated user'}</p>
<nav data-testid="learner-nav-mobile" className="mt-4 flex gap-2 overflow-x-auto pb-1 md:hidden" aria-label="Learner mobile navigation">
{LEARNER_NAV_ITEMS.map(item => {
const allowed = !item.minAnyRoles || hasAnyRole(roles, item.minAnyRoles)
if (!allowed) {
return (
<span
key={`mobile-${item.path}`}
className="inline-flex shrink-0 rounded-lg border border-slate-200 bg-stone-50 px-3 py-2 text-xs text-stone-400"
aria-disabled="true"
>
{item.label}
</span>
)
}
return (
<NavLink
key={`mobile-${item.path}`}
to={item.path}
end={item.path === '/learn'}
data-testid={`learner-nav-mobile-${item.path.replace(/^\//, '').replace(/\//g, '-') || 'home'}`}
className={({ isActive }) =>
`inline-flex shrink-0 rounded-lg px-3 py-2 text-xs font-medium transition ${
isActive
? 'bg-indigo-50 text-indigo-800'
: 'text-slate-700 hover:bg-stone-100 hover:text-stone-900'
}`
}
>
{item.label}
</NavLink>
)
})}
</nav>
<nav data-testid="learner-nav-desktop" className="mt-4 hidden space-y-1 md:block" aria-label="Learner desktop navigation">
{LEARNER_NAV_ITEMS.map(item => {
const allowed = !item.minAnyRoles || hasAnyRole(roles, item.minAnyRoles)
if (!allowed) {
return (
<span
key={item.path}
className="block rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-400"
aria-disabled="true"
>
{item.label} (restricted)
</span>
)
}
return (
<NavLink
key={item.path}
to={item.path}
end={item.path === '/learn'}
data-testid={`learner-nav-desktop-${item.path.replace(/^\//, '').replace(/\//g, '-') || 'home'}`}
className={({ isActive }) =>
`block rounded-lg px-3 py-2 text-sm font-medium transition ${
isActive
? 'bg-sky-100 text-sky-800'
: 'text-slate-700 hover:bg-slate-100 hover:text-slate-900'
}`
}
>
{item.label}
</NavLink>
)
})}
</nav>
</aside>
<section data-testid="learner-layout-content" className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm md:p-6">
<Outlet />
</section>
</div>
</main>
)
}

View File

@ -0,0 +1,79 @@
import { fetchLearnerLiveCourses } from './learner-live-courses'
import { fetchLearnerScormCourses } from './learner-scorm'
export type LearnerCertificateStatus = 'earned' | 'pending'
export type LearnerCertificateRecord = {
id: string
title: string
source: 'live_course' | 'scorm'
status: LearnerCertificateStatus
issuedOn?: string
requirement: string
relatedCoursePath: string
relatedSessionPath?: string
downloadAvailable: boolean
}
function normalizeStatus(status?: string): string {
return (status ?? '').trim().toLowerCase()
}
function certificateRequirementFromStatus(status: string): string {
if (status === 'completed') return 'Completion verified from enrollment status.'
if (status === 'in_progress') return 'Finish all required modules or sessions to unlock this certificate.'
if (status === 'not_started' || status === 'enrolled') return 'Start and complete this learning path to unlock this certificate.'
if (status === 'failed') return 'A passing completion is required before a certificate can be issued.'
return 'Certificate requirements are not yet fully tracked for this enrollment state.'
}
export async function fetchLearnerCertificates(): Promise<LearnerCertificateRecord[]> {
const [liveItems, scormItems] = await Promise.all([
fetchLearnerLiveCourses(),
fetchLearnerScormCourses(),
])
const liveRecords: LearnerCertificateRecord[] = liveItems
.filter(item => Boolean(item.course.id && item.course.title))
.map(item => {
const enrollmentStatus = normalizeStatus(item.enrollment.status)
const isEarned = enrollmentStatus === 'completed'
const courseSlug = (item.course.slug ?? '').trim() || item.course.id
const firstSession = item.upcomingSessions[0]
return {
id: `live-${item.course.id}-${item.cohort.id}`,
title: `${item.course.title} Certificate`,
source: 'live_course',
status: isEarned ? 'earned' : 'pending',
issuedOn: isEarned ? new Date().toISOString() : undefined,
requirement: certificateRequirementFromStatus(enrollmentStatus),
relatedCoursePath: `/learn/courses/${encodeURIComponent(courseSlug)}`,
relatedSessionPath: firstSession?.id
? `/learn/live/${encodeURIComponent(item.course.id)}/sessions/${encodeURIComponent(firstSession.id)}`
: undefined,
downloadAvailable: false,
}
})
const scormRecords: LearnerCertificateRecord[] = scormItems
.filter(item => Boolean(item.course.id && item.course.title))
.map(item => {
const enrollmentStatus = normalizeStatus(item.enrollment.status)
const isEarned = enrollmentStatus === 'completed'
return {
id: `scorm-${item.course.id}`,
title: `${item.course.title} Certificate`,
source: 'scorm',
status: isEarned ? 'earned' : 'pending',
issuedOn: isEarned ? new Date().toISOString() : undefined,
requirement: item.launchState.progressNote || certificateRequirementFromStatus(enrollmentStatus),
relatedCoursePath: `/learn/scorm/${encodeURIComponent(item.course.id)}`,
relatedSessionPath: undefined,
downloadAvailable: false,
}
})
return [...liveRecords, ...scormRecords].sort((a, b) => a.title.localeCompare(b.title))
}

View File

@ -0,0 +1,120 @@
import { apiRequest } from './api/client'
export type LearnerCommunity = {
id: string
name: string
slug?: string
description?: string
visibility?: string
accessPolicy?: string
}
export type LearnerCommunityChannel = {
id: string
name: string
slug?: string
topic?: string
isPrivate?: boolean
}
export type LearnerCommunityMembership = {
id: string
communityId: string
userId: string
status?: string
role?: string
source?: string
}
export type LearnerCommunityAccess = {
allowed: boolean
reason?: string
}
export type LearnerCommunityItem = {
community: LearnerCommunity
channels: LearnerCommunityChannel[]
membership?: LearnerCommunityMembership
access: LearnerCommunityAccess
}
function asString(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function asBool(value: unknown): boolean | undefined {
return typeof value === 'boolean' ? value : undefined
}
function normalizeCommunity(raw: unknown): LearnerCommunity {
const source = (raw ?? {}) as Record<string, unknown>
return {
id: asString(source.id),
name: asString(source.name),
slug: asString(source.slug) || undefined,
description: asString(source.description) || undefined,
visibility: asString(source.visibility) || undefined,
accessPolicy: asString(source.accessPolicy) || undefined,
}
}
function normalizeChannel(raw: unknown): LearnerCommunityChannel {
const source = (raw ?? {}) as Record<string, unknown>
return {
id: asString(source.id),
name: asString(source.name),
slug: asString(source.slug) || undefined,
topic: asString(source.topic) || undefined,
isPrivate: asBool(source.isPrivate),
}
}
function normalizeMembership(raw: unknown): LearnerCommunityMembership | undefined {
const source = (raw ?? {}) as Record<string, unknown>
const id = asString(source.id)
if (!id) return undefined
return {
id,
communityId: asString(source.communityId),
userId: asString(source.userId),
status: asString(source.status) || undefined,
role: asString(source.role) || undefined,
source: asString(source.source) || undefined,
}
}
function normalizeAccess(raw: unknown): LearnerCommunityAccess {
const source = (raw ?? {}) as Record<string, unknown>
return {
allowed: Boolean(source.allowed),
reason: asString(source.reason) || undefined,
}
}
export async function fetchLearnerCommunities(): Promise<LearnerCommunityItem[]> {
const result = await apiRequest<unknown[]>('/api/learner/communities')
if (!Array.isArray(result)) return []
return result.map(raw => {
const source = (raw ?? {}) as Record<string, unknown>
const channelsRaw = Array.isArray(source.channels) ? source.channels : []
return {
community: normalizeCommunity(source.community),
channels: channelsRaw.map(normalizeChannel).filter(channel => Boolean(channel.id && channel.name)),
membership: normalizeMembership(source.membership),
access: normalizeAccess(source.access),
}
}).filter(item => Boolean(item.community.id && item.community.name))
}
export async function joinLearnerCommunity(communityID: string): Promise<LearnerCommunityMembership> {
const result = await apiRequest<unknown>(`/api/learner/communities/${encodeURIComponent(communityID)}/join`, {
method: 'POST',
body: JSON.stringify({}),
})
const membership = normalizeMembership(result)
if (!membership) {
throw new Error('Join response did not include membership details')
}
return membership
}

View File

@ -0,0 +1,149 @@
import { fetchLearnerLiveCourses, type LearnerLiveCourseItem } from './learner-live-courses'
import { fetchPublicLiveCourses, fetchPublicLiveCourseBySlug, type LiveCourse } from './live-courses'
export type LearnerCourseCard = {
id: string
slug: string
title: string
description?: string
source: 'enrolled' | 'available'
statusLabel: string
progressLabel: string
actionLabel: string
locked: boolean
lockReason?: string
lockMessage?: string
}
export type LearnerCourseDetail = {
card: LearnerCourseCard
cohorts: {
id: string
title: string
status?: string
}[]
}
function normalizeCourseSlug(slug: string | undefined, fallbackID: string): string {
const trimmed = (slug ?? '').trim()
if (trimmed) return trimmed
return fallbackID
}
function buildEnrolledCard(item: LearnerLiveCourseItem): LearnerCourseCard {
const reason = item.access.reason
const status = item.enrollment.status || 'enrolled'
const locked = !item.access.allowed
return {
id: item.course.id,
slug: normalizeCourseSlug(item.course.slug, item.course.id),
title: item.course.title,
description: item.course.description,
source: 'enrolled',
statusLabel: status,
progressLabel: 'Progress tracking is not yet available for this course.',
actionLabel: 'Resume',
locked,
lockReason: reason,
lockMessage: locked ? (item.access.message || lockMessageForReason(reason)) : undefined,
}
}
function buildAvailableCard(course: LiveCourse): LearnerCourseCard {
return {
id: course.id,
slug: normalizeCourseSlug(course.slug, course.id),
title: course.title,
description: course.description,
source: 'available',
statusLabel: 'available',
progressLabel: 'Enroll to begin tracking progress in this course.',
actionLabel: 'Start',
locked: true,
lockReason: 'missing_enrollment',
lockMessage: lockMessageForReason('missing_enrollment'),
}
}
function lockMessageForReason(reason?: string): string {
if (reason === 'missing_entitlement') {
return 'This course is locked because your entitlement is missing or inactive.'
}
return 'This course is locked because you are not currently enrolled.'
}
function uniqueByCourse(items: LearnerLiveCourseItem[]): LearnerLiveCourseItem[] {
const byCourse = new Map<string, LearnerLiveCourseItem>()
for (const item of items) {
if (!item.course.id) continue
if (!byCourse.has(item.course.id)) {
byCourse.set(item.course.id, item)
continue
}
const existing = byCourse.get(item.course.id)
if (!existing) continue
const existingUnlocked = Boolean(existing.access.allowed)
const currentUnlocked = Boolean(item.access.allowed)
if (!existingUnlocked && currentUnlocked) {
byCourse.set(item.course.id, item)
}
}
return [...byCourse.values()]
}
export async function fetchLearnerCourseCards(): Promise<LearnerCourseCard[]> {
const [enrolledItems, publicCourses] = await Promise.all([
fetchLearnerLiveCourses(),
fetchPublicLiveCourses(),
])
const uniqueEnrolled = uniqueByCourse(enrolledItems)
const enrolledCards = uniqueEnrolled
.filter(item => Boolean(item.course.id && item.course.title))
.map(buildEnrolledCard)
const enrolledIDs = new Set(enrolledCards.map(card => card.id))
const availableCards = publicCourses
.filter(course => Boolean(course.id && course.title))
.filter(course => !enrolledIDs.has(course.id))
.map(buildAvailableCard)
return [...enrolledCards, ...availableCards].sort((a, b) => a.title.localeCompare(b.title))
}
export async function fetchLearnerCourseDetailBySlug(slug: string): Promise<LearnerCourseDetail | null> {
const cards = await fetchLearnerCourseCards()
const card = cards.find(item => item.slug === slug)
if (!card) {
return null
}
if (card.source === 'enrolled') {
const enrolledItems = await fetchLearnerLiveCourses()
const courseCohorts = enrolledItems
.filter(item => item.course.id === card.id)
.map(item => ({
id: item.cohort.id,
title: item.cohort.title,
status: item.enrollment.status,
}))
return {
card,
cohorts: courseCohorts,
}
}
const course = await fetchPublicLiveCourseBySlug(slug)
return {
card: {
...card,
title: course.title || card.title,
description: course.description || card.description,
},
cohorts: [],
}
}

View File

@ -0,0 +1,150 @@
import { apiRequest } from './api/client'
export type LearnerAccess = {
allowed: boolean
reason?: string
message?: string
}
export type LearnerLiveCourse = {
id: string
title: string
slug?: string
description?: string
}
export type LearnerLiveCohort = {
id: string
title: string
startsAt?: number
endsAt?: number
timezone?: string
}
export type LearnerLiveSession = {
id: string
title: string
status?: string
startsAt?: number
endsAt?: number
timezone?: string
meetingUrl?: string
access: LearnerAccess
}
export type LearnerLiveCourseItem = {
course: LearnerLiveCourse
cohort: LearnerLiveCohort
enrollment: {
id: string
status?: string
}
access: LearnerAccess
upcomingSessions: LearnerLiveSession[]
}
export type LearnerLiveSessionDetail = {
course: LearnerLiveCourse
cohort: LearnerLiveCohort
session: LearnerLiveSession
access: LearnerAccess
}
function asString(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function asNumber(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) return value
return undefined
}
function normalizeAccess(raw: unknown): LearnerAccess {
const source = (raw ?? {}) as Record<string, unknown>
return {
allowed: Boolean(source.allowed),
reason: asString(source.reason) || undefined,
message: asString(source.message) || undefined,
}
}
function normalizeCourse(raw: unknown): LearnerLiveCourse {
const source = (raw ?? {}) as Record<string, unknown>
return {
id: asString(source.id),
title: asString(source.title),
slug: asString(source.slug) || undefined,
description: asString(source.description) || undefined,
}
}
function normalizeCohort(raw: unknown): LearnerLiveCohort {
const source = (raw ?? {}) as Record<string, unknown>
return {
id: asString(source.id),
title: asString(source.title),
startsAt: asNumber(source.startsAt),
endsAt: asNumber(source.endsAt),
timezone: asString(source.timezone) || undefined,
}
}
function normalizeSession(raw: unknown): LearnerLiveSession {
const source = (raw ?? {}) as Record<string, unknown>
return {
id: asString(source.id),
title: asString(source.title),
status: asString(source.status) || undefined,
startsAt: asNumber(source.startsAt),
endsAt: asNumber(source.endsAt),
timezone: asString(source.timezone) || undefined,
meetingUrl: asString(source.meetingUrl) || undefined,
access: normalizeAccess(source.access),
}
}
export async function fetchLearnerLiveCourses(): Promise<LearnerLiveCourseItem[]> {
const result = await apiRequest<unknown[]>('/api/learner/live-courses', { skipAuth: false })
if (!Array.isArray(result)) return []
return result.map(raw => {
const source = (raw ?? {}) as Record<string, unknown>
const sessionItems = Array.isArray(source.upcomingSessions)
? source.upcomingSessions.map(normalizeSession).filter(session => Boolean(session.id && session.title))
: []
return {
course: normalizeCourse(source.course),
cohort: normalizeCohort(source.cohort),
enrollment: {
id: asString(((source.enrollment ?? {}) as Record<string, unknown>).id),
status: asString(((source.enrollment ?? {}) as Record<string, unknown>).status) || undefined,
},
access: normalizeAccess(source.access),
upcomingSessions: sessionItems,
}
}).filter(item => Boolean(item.course.id && item.course.title && item.cohort.id))
}
export async function fetchLearnerLiveSession(courseID: string, sessionID: string): Promise<LearnerLiveSessionDetail> {
const result = await apiRequest<unknown>(`/api/learner/live-courses/${encodeURIComponent(courseID)}/sessions/${encodeURIComponent(sessionID)}`)
const source = (result ?? {}) as Record<string, unknown>
return {
course: normalizeCourse(source.course),
cohort: normalizeCohort(source.cohort),
session: normalizeSession(source.session),
access: normalizeAccess(source.access),
}
}
export async function joinLearnerLiveSession(courseID: string, sessionID: string): Promise<{ joinUrl: string }> {
const result = await apiRequest<unknown>(`/api/learner/live-courses/${encodeURIComponent(courseID)}/sessions/${encodeURIComponent(sessionID)}/join`, {
method: 'POST',
body: JSON.stringify({}),
})
const source = (result ?? {}) as Record<string, unknown>
return {
joinUrl: asString(source.joinUrl),
}
}

View File

@ -0,0 +1,33 @@
export type LearnerNavItem = {
label: string
path: string
minAnyRoles?: string[]
}
export const LEARNER_NAV_ITEMS: LearnerNavItem[] = [
{ label: 'Dashboard', path: '/learn' },
{ label: 'My Courses', path: '/learn/courses' },
{ label: 'SCORM', path: '/learn/scorm' },
{ label: 'Live Sessions', path: '/learn/live' },
{ label: 'Certificates', path: '/learn/certificates' },
{ label: 'Community', path: '/learn/community' },
{ label: 'Billing', path: '/learn/billing', minAnyRoles: ['learner', 'admin'] },
{ label: 'Profile', path: '/learn/profile' },
{ label: 'Notifications', path: '/learn/notifications' },
]
export function hasAnyRole(roles: string[], required: string[]): boolean {
return required.some(role => roles.includes(role))
}
export function hasLearnerPortalAccess(roles: string[]): boolean {
return hasAnyRole(roles, ['learner', 'admin', 'instructor'])
}
export function isLearnerNavItemActive(itemPath: string, currentPath: string): boolean {
const item = itemPath.trim()
const current = currentPath.trim()
if (!item || !current) return false
if (item === '/learn') return current === '/learn'
return current === item || current.startsWith(`${item}/`)
}

View File

@ -0,0 +1,136 @@
import { apiRequest } from './api/client'
export type LearnerProfile = {
id: string
email?: string
username?: string
role?: string
roles: string[]
}
export type LearnerNotificationPreferences = {
courseAnnouncements: boolean
liveSessionReminders: boolean
productUpdates: boolean
billingAlerts: boolean
}
export type NotificationSaveTarget = 'backend' | 'local'
const STORAGE_KEY_PREFIX = 'cms-learner-notification-preferences-v1'
const inMemoryStorage = new Map<string, string>()
export const DEFAULT_LEARNER_NOTIFICATION_PREFERENCES: LearnerNotificationPreferences = {
courseAnnouncements: true,
liveSessionReminders: true,
productUpdates: false,
billingAlerts: true,
}
function asString(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function toBoolean(value: unknown): boolean {
return value === true
}
function keyForProfile(profileID?: string): string {
const id = (profileID ?? '').trim()
return id ? `${STORAGE_KEY_PREFIX}:${id}` : STORAGE_KEY_PREFIX
}
function defaultStorage(): Storage {
if (typeof window !== 'undefined' && window.localStorage) {
return window.localStorage
}
return {
get length() { return inMemoryStorage.size },
clear: () => inMemoryStorage.clear(),
getItem: (key: string) => inMemoryStorage.get(key) ?? null,
key: (index: number) => [...inMemoryStorage.keys()][index] ?? null,
removeItem: (key: string) => { inMemoryStorage.delete(key) },
setItem: (key: string, value: string) => { inMemoryStorage.set(key, value) },
}
}
function normalizePreferences(raw: unknown): LearnerNotificationPreferences {
const source = (raw ?? {}) as Record<string, unknown>
return {
courseAnnouncements: source.courseAnnouncements === undefined
? DEFAULT_LEARNER_NOTIFICATION_PREFERENCES.courseAnnouncements
: toBoolean(source.courseAnnouncements),
liveSessionReminders: source.liveSessionReminders === undefined
? DEFAULT_LEARNER_NOTIFICATION_PREFERENCES.liveSessionReminders
: toBoolean(source.liveSessionReminders),
productUpdates: source.productUpdates === undefined
? DEFAULT_LEARNER_NOTIFICATION_PREFERENCES.productUpdates
: toBoolean(source.productUpdates),
billingAlerts: source.billingAlerts === undefined
? DEFAULT_LEARNER_NOTIFICATION_PREFERENCES.billingAlerts
: toBoolean(source.billingAlerts),
}
}
export async function fetchLearnerProfile(): Promise<LearnerProfile> {
const result = await apiRequest<unknown>('/auth/me')
const source = (result ?? {}) as Record<string, unknown>
const role = asString(source.role)
const roles = Array.isArray(source.roles)
? source.roles.map(asString).filter(Boolean)
: []
return {
id: asString(source.id),
email: asString(source.email) || undefined,
username: asString(source.username) || undefined,
role: role || undefined,
roles,
}
}
export function readLearnerNotificationPreferences(
profileID?: string,
storage: Storage = defaultStorage(),
): LearnerNotificationPreferences {
const raw = storage.getItem(keyForProfile(profileID))
if (!raw) return DEFAULT_LEARNER_NOTIFICATION_PREFERENCES
try {
return normalizePreferences(JSON.parse(raw) as unknown)
} catch {
return DEFAULT_LEARNER_NOTIFICATION_PREFERENCES
}
}
export function saveLearnerNotificationPreferencesLocal(
preferences: LearnerNotificationPreferences,
profileID?: string,
storage: Storage = defaultStorage(),
): LearnerNotificationPreferences {
const normalized = normalizePreferences(preferences)
storage.setItem(keyForProfile(profileID), JSON.stringify(normalized))
return normalized
}
export async function saveLearnerNotificationPreferences(
preferences: LearnerNotificationPreferences,
profileID?: string,
): Promise<{ target: NotificationSaveTarget; preferences: LearnerNotificationPreferences }> {
const normalized = saveLearnerNotificationPreferencesLocal(preferences, profileID)
const id = (profileID ?? '').trim()
if (!id) {
return { target: 'local', preferences: normalized }
}
try {
await apiRequest(`/api/users/${encodeURIComponent(id)}`, {
method: 'PATCH',
body: JSON.stringify({ notificationPreferences: normalized }),
})
return { target: 'backend', preferences: normalized }
} catch {
return { target: 'local', preferences: normalized }
}
}

View File

@ -0,0 +1,117 @@
import { apiRequest } from './api/client'
export type LearnerScormCourse = {
id: string
title: string
description?: string
subject?: string
gradeLevel?: string
moduleTypes: string[]
}
export type LearnerScormEnrollment = {
id: string
status?: string
}
export type LearnerScormLaunchState = {
allowed: boolean
state: 'ready' | 'locked' | 'missing_package' | 'export_not_ready'
reason?: string
message?: string
launchUrl?: string
resumeAvailable: boolean
progressNote?: string
}
export type LearnerScormCourseItem = {
course: LearnerScormCourse
enrollment: LearnerScormEnrollment
launchState: LearnerScormLaunchState
}
export type LearnerScormLaunchDetail = {
course: LearnerScormCourse
enrollment: LearnerScormEnrollment
launchState: LearnerScormLaunchState
}
function asString(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function normalizeCourse(raw: unknown): LearnerScormCourse {
const source = (raw ?? {}) as Record<string, unknown>
const scos = Array.isArray(source.scos) ? source.scos : []
const moduleTypes = scos
.map(item => {
const entry = (item ?? {}) as Record<string, unknown>
return asString(entry.moduleType)
})
.filter(Boolean)
return {
id: asString(source.id),
title: asString(source.title),
description: asString(source.description) || undefined,
subject: asString(source.subject) || undefined,
gradeLevel: asString(source.gradeLevel) || undefined,
moduleTypes: [...new Set(moduleTypes)],
}
}
function normalizeEnrollment(raw: unknown): LearnerScormEnrollment {
const source = (raw ?? {}) as Record<string, unknown>
return {
id: asString(source.id),
status: asString(source.status) || undefined,
}
}
function normalizeLaunchState(raw: unknown): LearnerScormLaunchState {
const source = (raw ?? {}) as Record<string, unknown>
const state = asString(source.state)
const normalizedState = state === 'ready' || state === 'locked' || state === 'missing_package' || state === 'export_not_ready'
? state
: 'locked'
const reason = asString(source.reason) || undefined
const message = asString(source.message) || (reason === 'missing_entitlement'
? 'Access is locked because your entitlement is missing or inactive.'
: undefined)
return {
allowed: Boolean(source.allowed),
state: normalizedState,
reason,
message,
launchUrl: asString(source.launchUrl) || undefined,
resumeAvailable: Boolean(source.resumeAvailable),
progressNote: asString(source.progressNote) || undefined,
}
}
export async function fetchLearnerScormCourses(): Promise<LearnerScormCourseItem[]> {
const result = await apiRequest<unknown[]>('/api/learner/scorm', { skipAuth: false })
if (!Array.isArray(result)) return []
return result
.map(raw => {
const source = (raw ?? {}) as Record<string, unknown>
return {
course: normalizeCourse(source.course),
enrollment: normalizeEnrollment(source.enrollment),
launchState: normalizeLaunchState(source.launchState),
}
})
.filter(item => Boolean(item.course.id && item.course.title))
}
export async function fetchLearnerScormLaunch(courseID: string): Promise<LearnerScormLaunchDetail> {
const result = await apiRequest<unknown>(`/api/learner/scorm/${encodeURIComponent(courseID)}/launch`, { skipAuth: false })
const source = (result ?? {}) as Record<string, unknown>
return {
course: normalizeCourse(source.course),
enrollment: normalizeEnrollment(source.enrollment),
launchState: normalizeLaunchState(source.launchState),
}
}

View File

@ -0,0 +1,47 @@
type LiveCourseSeoInput = {
title: string
description?: string
canonicalUrl?: string
}
function upsertMeta(name: string, content: string, attr: 'name' | 'property' = 'name') {
const selector = `meta[${attr}="${name}"]`
let element = document.head.querySelector(selector) as HTMLMetaElement | null
if (!element) {
element = document.createElement('meta')
element.setAttribute(attr, name)
document.head.appendChild(element)
}
element.setAttribute('content', content)
}
function upsertCanonical(url?: string) {
const existing = document.head.querySelector('link[rel="canonical"]') as HTMLLinkElement | null
if (!url) {
existing?.remove()
return
}
if (existing) {
existing.href = url
return
}
const link = document.createElement('link')
link.rel = 'canonical'
link.href = url
document.head.appendChild(link)
}
export function applyLiveCourseSeo(input: LiveCourseSeoInput): void {
document.title = input.title
upsertMeta('og:type', 'website', 'property')
upsertMeta('og:title', input.title, 'property')
if (input.description) {
upsertMeta('description', input.description)
upsertMeta('og:description', input.description, 'property')
}
upsertCanonical(input.canonicalUrl)
}

View File

@ -0,0 +1,103 @@
import { apiRequest } from './api/client'
export type LiveCourseStatus = 'draft' | 'scheduled' | 'published' | 'archived'
export type LiveCourseVisibility = 'public' | 'authenticated' | 'private' | 'organisation'
export type LiveCourse = {
id: string
title: string
slug: string
description?: string
status?: LiveCourseStatus
visibility?: LiveCourseVisibility
deliveryMode?: string
level?: string
durationSummary?: string
seo?: {
title?: string
description?: string
canonicalUrl?: string
}
}
export type LiveCourseCohort = {
id: string
title: string
status?: string
timezone?: string
startsAt?: number
endsAt?: number
capacity?: number
enrolledCount?: number
waitlistEnabled?: boolean
}
function asString(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function asNumber(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) return value
return undefined
}
function asBoolean(value: unknown): boolean | undefined {
if (typeof value === 'boolean') return value
return undefined
}
function normalizeLiveCourse(raw: unknown): LiveCourse {
const source = (raw ?? {}) as Record<string, unknown>
const seoSource = source.seo as Record<string, unknown> | undefined
return {
id: asString(source.id),
title: asString(source.title),
slug: asString(source.slug),
description: asString(source.description) || undefined,
status: asString(source.status) as LiveCourseStatus,
visibility: asString(source.visibility) as LiveCourseVisibility,
deliveryMode: asString(source.deliveryMode) || undefined,
level: asString(source.level) || undefined,
durationSummary: asString(source.durationSummary) || undefined,
seo: seoSource
? {
title: asString(seoSource.title) || undefined,
description: asString(seoSource.description) || undefined,
canonicalUrl: asString(seoSource.canonicalUrl) || undefined,
}
: undefined,
}
}
function normalizeCohort(raw: unknown): LiveCourseCohort {
const source = (raw ?? {}) as Record<string, unknown>
return {
id: asString(source.id),
title: asString(source.title),
status: asString(source.status) || undefined,
timezone: asString(source.timezone) || undefined,
startsAt: asNumber(source.startsAt),
endsAt: asNumber(source.endsAt),
capacity: asNumber(source.capacity),
enrolledCount: asNumber(source.enrolledCount),
waitlistEnabled: asBoolean(source.waitlistEnabled),
}
}
export async function fetchPublicLiveCourses(): Promise<LiveCourse[]> {
const result = await apiRequest<unknown[]>('/api/public/live-courses', { skipAuth: true })
if (!Array.isArray(result)) return []
return result.map(normalizeLiveCourse).filter(course => Boolean(course.slug && course.title))
}
export async function fetchPublicLiveCourseBySlug(slug: string): Promise<LiveCourse> {
const result = await apiRequest<unknown>(`/api/public/live-courses/${encodeURIComponent(slug)}`, { skipAuth: true })
return normalizeLiveCourse(result)
}
export async function fetchPublicLiveCourseCohortsBySlug(slug: string): Promise<LiveCourseCohort[]> {
const result = await apiRequest<unknown[]>(`/api/public/live-courses/${encodeURIComponent(slug)}/cohorts`, { skipAuth: true })
if (!Array.isArray(result)) return []
return result.map(normalizeCohort).filter(cohort => Boolean(cohort.id && cohort.title))
}

View File

@ -0,0 +1,115 @@
import type { Page, ThemeConfig, CanvasBlock, DataSource, CustomBlockDef } from '../types'
import type { ScormCourse, ScormSCO, ScormQuestion } from './mcp-types'
// ── Event map ─────────────────────────────────────────────────
export type MCPEventMap = {
// CMS Builder — page lifecycle
'cms:page:created': { page: Page }
'cms:page:saved': { page: Page }
'cms:page:loaded': { page: Page }
'cms:page:deleted': { pageId: string; name: string }
'cms:page:cleared': { pageId: string | null }
// CMS Builder — block lifecycle
'cms:block:added': { pageId: string | null; block: CanvasBlock; parentId?: string; slotIndex?: number }
'cms:block:removed': { pageId: string | null; instanceId: string }
'cms:block:moved': { pageId: string | null; instanceId: string; direction: 'up' | 'down' }
'cms:block:content': { pageId: string | null; instanceId: string; key: string; value: string }
'cms:block:style': { pageId: string | null; instanceId: string; key: string; value: string }
'cms:block:selected': { instanceId: string | null }
// CMS Builder — theme
'cms:theme:changed': { theme: ThemeConfig }
// CMS Builder — preview
'cms:preview:opened': { pageId: string | null }
'cms:preview:closed': { pageId: string | null }
// CMS Builder — data sources
'cms:datasource:saved': { source: DataSource }
'cms:datasource:deleted': { sourceId: string }
'cms:datasource:refreshed': { sourceId: string }
// SCORM — course lifecycle
'scorm:course:saved': { course: ScormCourse }
'scorm:course:exported': { courseId: string; version: string }
// SCORM — SCO lifecycle
'scorm:sco:added': { sco: ScormSCO }
'scorm:sco:removed': { scoId: string }
'scorm:sco:moved': { scoId: string; direction: number }
'scorm:sco:updated': { scoId: string; patch: Partial<ScormSCO> }
'scorm:sco:selected': { scoId: string | null }
// SCORM — question lifecycle
'scorm:question:added': { scoId: string; question: ScormQuestion }
'scorm:question:removed': { scoId: string; questionId: string }
'scorm:question:updated': { scoId: string; question: ScormQuestion }
'scorm:question:moved': { scoId: string; questionId: string; direction: number }
// SCORM — preview
'scorm:preview:opened': { scoId: string }
'scorm:preview:submitted': { scoId: string; score: number; passed: boolean; answers: Record<string, unknown> }
'scorm:preview:closed': { scoId: string }
// Admin
'admin:user:created': { userId: string; role: string }
'admin:user:updated': { userId: string; patch: Record<string, unknown> }
'admin:user:deleted': { userId: string }
'admin:enrolled': { userId: string; courseId: string }
}
export type MCPEventName = keyof MCPEventMap
export type MCPEventPayload<K extends MCPEventName> = MCPEventMap[K]
export const MCP_EVENTS: MCPEventName[] = [
'cms:page:created', 'cms:page:saved', 'cms:page:loaded', 'cms:page:deleted', 'cms:page:cleared',
'cms:block:added', 'cms:block:removed', 'cms:block:moved', 'cms:block:content', 'cms:block:style', 'cms:block:selected',
'cms:theme:changed', 'cms:preview:opened', 'cms:preview:closed',
'cms:datasource:saved', 'cms:datasource:deleted', 'cms:datasource:refreshed',
'scorm:course:saved', 'scorm:course:exported',
'scorm:sco:added', 'scorm:sco:removed', 'scorm:sco:moved', 'scorm:sco:updated', 'scorm:sco:selected',
'scorm:question:added', 'scorm:question:removed', 'scorm:question:updated', 'scorm:question:moved',
'scorm:preview:opened', 'scorm:preview:submitted', 'scorm:preview:closed',
'admin:user:created', 'admin:user:updated', 'admin:user:deleted', 'admin:enrolled',
]
export type MCPEventEntry = {
id: string
event: MCPEventName
payload: MCPEventMap[MCPEventName]
timestamp: number
}
type Handler<K extends MCPEventName> = (payload: MCPEventPayload<K>) => void
// ── Event Bus ─────────────────────────────────────────────────
class EventBusClass {
private listeners = new Map<string, Set<Function>>()
private _log: MCPEventEntry[] = []
private logListeners = new Set<(entry: MCPEventEntry) => void>()
private _seq = 0
on<K extends MCPEventName>(event: K, handler: Handler<K>) {
if (!this.listeners.has(event)) this.listeners.set(event, new Set())
this.listeners.get(event)!.add(handler as Function)
}
off<K extends MCPEventName>(event: K, handler: Handler<K>) {
this.listeners.get(event)?.delete(handler as Function)
}
emit<K extends MCPEventName>(event: K, payload: MCPEventPayload<K>) {
const entry: MCPEventEntry = {
id: `evt-${++this._seq}`,
event, payload: payload as MCPEventMap[MCPEventName],
timestamp: Date.now(),
}
this._log = [entry, ...this._log].slice(0, 200)
this.listeners.get(event)?.forEach(h => { try { h(payload) } catch {} })
this.logListeners.forEach(h => { try { h(entry) } catch {} })
// Also dispatch as DOM custom event for external consumers
try {
window.dispatchEvent(new CustomEvent('cms:mcp:event', { detail: entry }))
} catch {}
}
get log() { return this._log }
onLog(handler: (entry: MCPEventEntry) => void) {
this.logListeners.add(handler)
return () => this.logListeners.delete(handler)
}
}
export const EventBus = new EventBusClass()

View File

@ -0,0 +1,260 @@
import { ApiError, createResource, deleteResource, listResource, patchResource } from '../api/client'
import { EventBus, MCP_EVENTS, type MCPEventMap, type MCPEventName } from './EventBus'
export type Webhook = {
id: string
name: string
url: string
events: MCPEventName[]
secret: string
enabled: boolean
createdAt: number
lastFiredAt: number | null
lastStatus: number | null
lastError?: string | null
}
type EventHandler<K extends MCPEventName> = (payload: MCPEventMap[K]) => void
type RuntimeState = {
installed: boolean
listeners: Map<MCPEventName, EventHandler<MCPEventName>>
webhooksById: Map<string, Webhook>
}
const runtime: RuntimeState = {
installed: false,
listeners: new Map(),
webhooksById: new Map(),
}
const DEFAULT_TIMEOUT_MS = 10_000
const DEFAULT_MAX_ATTEMPTS = 3
const RETRYABLE_STATUSES = new Set([0, 408, 425, 429, 500, 502, 503, 504])
function randomInt(maxExclusive: number) {
const bytes = new Uint32Array(1)
globalThis.crypto.getRandomValues(bytes)
return bytes[0] % maxExclusive
}
function uid() {
return randomInt(0x7fffffff).toString(36).slice(0, 7)
}
function normalizeWebhook(input: Webhook): Webhook {
const events = Array.from(new Set((input.events ?? []).filter((event): event is MCPEventName => MCP_EVENTS.includes(event))))
return {
...input,
events,
enabled: Boolean(input.enabled),
lastFiredAt: input.lastFiredAt ?? null,
lastStatus: input.lastStatus ?? null,
lastError: input.lastError ?? null,
}
}
function sleep(ms: number) {
return new Promise<void>(resolve => setTimeout(resolve, ms))
}
function shouldRetry(status: number) {
return RETRYABLE_STATUSES.has(status)
}
async function signWebhookPayload(secret: string, timestamp: number, body: string): Promise<string | null> {
try {
if (!globalThis.crypto?.subtle) return null
const encoder = new TextEncoder()
const key = await globalThis.crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
)
const signatureBuffer = await globalThis.crypto.subtle.sign('HMAC', key, encoder.encode(`${timestamp}.${body}`))
const signature = Array.from(new Uint8Array(signatureBuffer)).map(b => b.toString(16).padStart(2, '0')).join('')
return `t=${timestamp},sha256=${signature}`
} catch {
return null
}
}
async function refreshWebhookCache() {
const webhooks = await loadWebhooks()
runtime.webhooksById = new Map(webhooks.map(webhook => [webhook.id, normalizeWebhook(webhook)]))
}
async function patchDeliveryStatus(webhook: Webhook, status: number, firedAt: number, errorMessage: string | null) {
try {
await patchResource<Webhook>('webhooks', webhook.id, {
lastFiredAt: firedAt,
lastStatus: status,
lastError: errorMessage,
})
runtime.webhooksById.set(webhook.id, {
...webhook,
lastFiredAt: firedAt,
lastStatus: status,
lastError: errorMessage,
})
} catch (error) {
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
return
}
}
}
export async function loadWebhooks(): Promise<Webhook[]> {
try {
const webhooks = await listResource<Webhook>('webhooks')
return webhooks.map(normalizeWebhook)
} catch (error) {
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
return []
}
throw error
}
}
export async function saveWebhooks(nextWebhooks: Webhook[]): Promise<void> {
try {
const desired = nextWebhooks.map(normalizeWebhook)
const existing = await listResource<Webhook>('webhooks')
const existingById = new Map(existing.map(webhook => [webhook.id, normalizeWebhook(webhook)]))
for (const webhook of desired) {
if (existingById.has(webhook.id)) {
await patchResource('webhooks', webhook.id, webhook as unknown as Record<string, unknown>)
existingById.delete(webhook.id)
} else {
await createResource('webhooks', webhook as unknown as Record<string, unknown>)
}
}
for (const stale of existingById.values()) {
await deleteResource('webhooks', stale.id)
}
runtime.webhooksById = new Map(desired.map(webhook => [webhook.id, webhook]))
} catch (error) {
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
runtime.webhooksById = new Map()
return
}
throw error
}
}
export function createWebhook(name: string, url: string, events: MCPEventName[]): Webhook {
return {
id: `wh-${uid()}`,
name,
url,
events,
secret: uid() + uid(),
enabled: true,
createdAt: Date.now(),
lastFiredAt: null,
lastStatus: null,
lastError: null,
}
}
export async function fireWebhook(webhook: Webhook, event: MCPEventName, payload: unknown): Promise<number> {
const firedAt = Date.now()
const body = JSON.stringify({ event, payload, timestamp: firedAt, webhookId: webhook.id })
const signature = await signWebhookPayload(webhook.secret, firedAt, body)
for (let attempt = 0; attempt < DEFAULT_MAX_ATTEMPTS; attempt += 1) {
let timeoutHandle: ReturnType<typeof setTimeout> | undefined
const controller = new AbortController()
timeoutHandle = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS)
try {
const res = await fetch(webhook.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-MCP-Secret': webhook.secret,
'X-MCP-Event': event,
...(signature ? { 'X-MCP-Signature': signature } : {}),
},
body,
signal: controller.signal,
})
if (timeoutHandle) clearTimeout(timeoutHandle)
if (shouldRetry(res.status) && attempt < DEFAULT_MAX_ATTEMPTS - 1) {
const jitterMs = randomInt(100)
const waitMs = 250 * (2 ** attempt) + jitterMs
await sleep(waitMs)
continue
}
return res.status
} catch {
if (timeoutHandle) clearTimeout(timeoutHandle)
if (attempt < DEFAULT_MAX_ATTEMPTS - 1) {
const jitterMs = randomInt(100)
const waitMs = 250 * (2 ** attempt) + jitterMs
await sleep(waitMs)
continue
}
return 0
}
}
return 0
}
async function dispatchForEvent<K extends MCPEventName>(event: K, payload: MCPEventMap[K]) {
const candidates = Array.from(runtime.webhooksById.values())
.filter(webhook => webhook.enabled && webhook.events.includes(event))
if (candidates.length === 0) {
return
}
await Promise.allSettled(candidates.map(async webhook => {
const firedAt = Date.now()
const status = await fireWebhook(webhook, event, payload)
const errorMessage = status === 0 ? 'network_error_or_timeout' : null
await patchDeliveryStatus(webhook, status, firedAt, errorMessage)
}))
}
export async function installWebhooks(): Promise<() => void> {
if (runtime.installed) {
return uninstallWebhooks
}
runtime.installed = true
await refreshWebhookCache()
MCP_EVENTS.forEach(event => {
const handler: EventHandler<typeof event> = (payload) => {
void dispatchForEvent(event, payload)
}
runtime.listeners.set(event, handler as EventHandler<MCPEventName>)
EventBus.on(event, handler)
})
return uninstallWebhooks
}
export function uninstallWebhooks() {
runtime.listeners.forEach((handler, event) => {
EventBus.off(event, handler)
})
runtime.listeners.clear()
runtime.installed = false
}
export function resetWebhookRuntimeForTests() {
uninstallWebhooks()
runtime.webhooksById = new Map()
}

View File

@ -0,0 +1,27 @@
import { installWindowInterface } from './tools'
import { installWebhooks } from './WebhookStore'
let cleanup: (() => void) | null = null
let bootstrapping: Promise<(() => void)> | null = null
export async function startMcpRuntime(): Promise<() => void> {
if (cleanup) {
return cleanup
}
if (!bootstrapping) {
bootstrapping = (async () => {
installWindowInterface()
const webhookCleanup = await installWebhooks()
cleanup = () => {
webhookCleanup()
cleanup = null
bootstrapping = null
}
bootstrapping = null
return cleanup
})()
}
return await bootstrapping
}

View File

@ -0,0 +1,2 @@
// Re-export SCORM types needed by EventBus without circular deps
export type { ScormCourse, ScormSCO, ScormQuestion, ScormVersion, ScormSettings } from '../pages/ScormPage'

View File

@ -0,0 +1,511 @@
import { loadPages, savePages, loadDataSources, saveDataSources } from '../cms-utils'
import type { Page, DataSource, CanvasBlock, ThemeConfig } from '../types'
import { EventBus } from './EventBus'
import { createResource, listResource, patchResource, syncCollection } from '../api/client'
// ── Tool definition types ─────────────────────────────────────
export type ToolParam = {
name: string
type: 'string' | 'number' | 'boolean' | 'object' | 'array'
description: string
required?: boolean
}
export type ToolDef = {
name: string
description: string
category: 'cms' | 'scorm' | 'admin'
params: ToolParam[]
handler: (args: Record<string, unknown>) => Promise<unknown>
}
// ── Helpers ───────────────────────────────────────────────────
function uid() {
const bytes = new Uint32Array(1)
globalThis.crypto.getRandomValues(bytes)
return bytes[0].toString(36).slice(0, 7)
}
async function loadScorm() {
const courses = await listResource<Record<string, unknown>>('scorm')
return courses[0] ?? null
}
async function saveScorm(c: Record<string, unknown>) {
const courses = await listResource<{ id: string } & Record<string, unknown>>('scorm')
const id = String(c.id)
if (courses.some(course => course.id === id)) {
await patchResource('scorm', id, c)
} else {
await createResource('scorm', c)
}
}
async function loadAdminUsers() {
return await listResource<Record<string, unknown>>('users')
}
async function saveAdminUsers(users: Array<Record<string, unknown>>) {
await syncCollection('users', users as Array<{ id: string } & Record<string, unknown>>)
}
async function loadEnrollments() {
return await listResource<Record<string, unknown>>('enrollments')
}
async function saveEnrollments(enrollments: Array<Record<string, unknown>>) {
await syncCollection('enrollments', enrollments as Array<{ id: string } & Record<string, unknown>>)
}
// ── Tool registry ─────────────────────────────────────────────
export const TOOLS: ToolDef[] = [
// ── CMS: Pages ───────────────────────────────────────────────
{
name: 'cms_list_pages',
category: 'cms',
description: 'List all saved CMS pages with their metadata.',
params: [],
async handler() {
const pages = await loadPages()
return pages.map(p => ({ id: p.id, name: p.name, blockCount: p.blocks.length, updatedAt: p.updatedAt }))
},
},
{
name: 'cms_get_page',
category: 'cms',
description: 'Get the full content of a CMS page including all blocks and theme.',
params: [{ name: 'id', type: 'string', description: 'Page ID', required: true }],
async handler({ id }) {
const pages = await loadPages()
const page = pages.find(p => p.id === id)
if (!page) throw new Error(`Page "${id}" not found`)
return page
},
},
{
name: 'cms_create_page',
category: 'cms',
description: 'Create a new empty CMS page and save it.',
params: [
{ name: 'name', type: 'string', description: 'Page name', required: true },
],
async handler({ name }) {
const pages = await loadPages()
const now = Date.now()
const page: Page = {
id: `page-${uid()}`, name: String(name), blocks: [],
theme: { background: '#ffffff', foreground: '#09090b', primary: '#4f46e5', primaryForeground: '#fafafa', muted: '#f4f4f5', mutedForeground: '#71717a', border: '#e4e4e7', accent: '#f4f4f5', radius: 0.5, fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif' },
customBlocks: [], createdAt: now, updatedAt: now,
}
await savePages([page, ...pages])
EventBus.emit('cms:page:created', { page })
return { success: true, page }
},
},
{
name: 'cms_update_page',
category: 'cms',
description: 'Update the name or theme of a CMS page.',
params: [
{ name: 'id', type: 'string', description: 'Page ID', required: true },
{ name: 'name', type: 'string', description: 'New page name' },
{ name: 'theme', type: 'object', description: 'Partial ThemeConfig to merge' },
],
async handler({ id, name, theme }) {
const pages = await loadPages()
const idx = pages.findIndex(p => p.id === id)
if (idx === -1) throw new Error(`Page "${id}" not found`)
const updated = { ...pages[idx], updatedAt: Date.now(), ...(name ? { name: String(name) } : {}), ...(theme ? { theme: { ...pages[idx].theme, ...(theme as Partial<ThemeConfig>) } } : {}) }
pages[idx] = updated
await savePages(pages)
EventBus.emit('cms:page:saved', { page: updated })
return { success: true, page: updated }
},
},
{
name: 'cms_delete_page',
category: 'cms',
description: 'Delete a CMS page permanently.',
params: [{ name: 'id', type: 'string', description: 'Page ID', required: true }],
async handler({ id }) {
const pages = await loadPages()
const existing = pages.find(page => page.id === String(id))
const filtered = pages.filter(p => p.id !== String(id))
if (filtered.length === pages.length) throw new Error(`Page "${id}" not found`)
await savePages(filtered)
if (existing) {
EventBus.emit('cms:page:deleted', { pageId: existing.id, name: existing.name })
}
return { success: true, deleted: id }
},
},
{
name: 'cms_add_block',
category: 'cms',
description: 'Add a block to a CMS page by block type ID. Available block IDs: hero, text, image, button, columns, card, list, divider, spacer, video, testimonial, cta, pricing, faq, stats.',
params: [
{ name: 'pageId', type: 'string', description: 'Page ID', required: true },
{ name: 'blockId', type: 'string', description: 'Block type ID (e.g. "hero", "text")', required: true },
{ name: 'content', type: 'object', description: 'Optional initial content key/value pairs' },
],
async handler({ pageId, blockId, content }) {
const pages = await loadPages()
const idx = pages.findIndex(p => p.id === pageId)
if (idx === -1) throw new Error(`Page "${pageId}" not found`)
const block: CanvasBlock = {
instanceId: `${blockId}-${uid()}`, blockId: String(blockId),
content: { ...(content as Record<string, string> ?? {}) }, styles: {},
}
pages[idx] = { ...pages[idx], blocks: [...pages[idx].blocks, block], updatedAt: Date.now() }
await savePages(pages)
EventBus.emit('cms:block:added', { pageId: String(pageId), block })
return { success: true, block }
},
},
{
name: 'cms_remove_block',
category: 'cms',
description: 'Remove a block from a CMS page by its instance ID.',
params: [
{ name: 'pageId', type: 'string', description: 'Page ID', required: true },
{ name: 'instanceId', type: 'string', description: 'Block instance ID', required: true },
],
async handler({ pageId, instanceId }) {
const { removeBlockFromTree } = await import('../cms-utils')
const pages = await loadPages()
const idx = pages.findIndex(p => p.id === pageId)
if (idx === -1) throw new Error(`Page "${pageId}" not found`)
pages[idx] = { ...pages[idx], blocks: removeBlockFromTree(pages[idx].blocks, String(instanceId)), updatedAt: Date.now() }
await savePages(pages)
EventBus.emit('cms:block:removed', { pageId: String(pageId), instanceId: String(instanceId) })
return { success: true }
},
},
{
name: 'cms_update_block_content',
category: 'cms',
description: 'Update a specific content field on a block within a CMS page.',
params: [
{ name: 'pageId', type: 'string', description: 'Page ID', required: true },
{ name: 'instanceId', type: 'string', description: 'Block instance ID', required: true },
{ name: 'key', type: 'string', description: 'Content field key', required: true },
{ name: 'value', type: 'string', description: 'New value', required: true },
],
async handler({ pageId, instanceId, key, value }) {
const { updateBlockInTree } = await import('../cms-utils')
const pages = await loadPages()
const idx = pages.findIndex(p => p.id === pageId)
if (idx === -1) throw new Error(`Page "${pageId}" not found`)
pages[idx] = {
...pages[idx], updatedAt: Date.now(),
blocks: updateBlockInTree(pages[idx].blocks, String(instanceId), b => ({ ...b, content: { ...b.content, [String(key)]: String(value) } })),
}
await savePages(pages)
EventBus.emit('cms:block:content', { pageId: String(pageId), instanceId: String(instanceId), key: String(key), value: String(value) })
return { success: true }
},
},
// ── CMS: Data Sources ─────────────────────────────────────────
{
name: 'cms_list_datasources',
category: 'cms',
description: 'List all configured data sources.',
params: [],
async handler() { return await loadDataSources() },
},
{
name: 'cms_add_datasource',
category: 'cms',
description: 'Add a new static JSON data source.',
params: [
{ name: 'name', type: 'string', description: 'Data source name', required: true },
{ name: 'staticData', type: 'string', description: 'JSON string of array data', required: true },
],
async handler({ name, staticData }) {
const sources = await loadDataSources()
const source: DataSource = {
id: `ds-${uid()}`, name: String(name), type: 'static',
url: '', method: 'GET', headers: '{}', body: '', staticData: String(staticData), path: '',
}
await saveDataSources([source, ...sources])
EventBus.emit('cms:datasource:saved', { source })
return { success: true, source }
},
},
// ── SCORM: Course ─────────────────────────────────────────────
{
name: 'scorm_get_course',
category: 'scorm',
description: 'Get the full SCORM course with all SCOs and questions.',
params: [],
async handler() { return await loadScorm() ?? { error: 'No SCORM course found' } },
},
{
name: 'scorm_update_course',
category: 'scorm',
description: 'Update top-level SCORM course metadata (title, description, version, language, author).',
params: [
{ name: 'title', type: 'string', description: 'Course title' },
{ name: 'description', type: 'string', description: 'Course description' },
{ name: 'version', type: 'string', description: 'SCORM version: scorm12 | scorm2004v3 | scorm2004v4' },
{ name: 'language', type: 'string', description: 'Language code (e.g. "en")' },
{ name: 'author', type: 'string', description: 'Author name' },
],
async handler(patch) {
const course = await loadScorm()
if (!course) throw new Error('No SCORM course found')
const updated = { ...course, ...patch, updatedAt: Date.now() }
await saveScorm(updated)
EventBus.emit('scorm:course:saved', { course: updated as any })
return { success: true, course: updated }
},
},
{
name: 'scorm_list_scos',
category: 'scorm',
description: 'List all SCOs (lessons) in the SCORM course.',
params: [],
async handler() {
const course = await loadScorm()
if (!course) throw new Error('No SCORM course found')
return course.scos.map((s: Record<string, unknown>) => ({ id: s.id, title: s.title, type: s.type, questionCount: (s.questions as unknown[]).length }))
},
},
{
name: 'scorm_add_sco',
category: 'scorm',
description: 'Add a new SCO (lesson) to the SCORM course.',
params: [
{ name: 'type', type: 'string', description: 'SCO type: quiz | content | survey', required: true },
{ name: 'title', type: 'string', description: 'SCO title' },
],
async handler({ type, title }) {
const course = await loadScorm()
if (!course) throw new Error('No SCORM course found')
const sco = { id: `sco-${uid()}`, title: title ?? (type === 'content' ? 'Content Page' : type === 'survey' ? 'Survey' : 'Quiz'), type, description: '', questions: type === 'content' ? [] : [], contentHtml: '', blocks: [], passingScoreOverride: null }
const updated = { ...course, scos: [...course.scos, sco], updatedAt: Date.now() }
await saveScorm(updated)
EventBus.emit('scorm:sco:added', { sco })
return { success: true, sco }
},
},
{
name: 'scorm_remove_sco',
category: 'scorm',
description: 'Remove a SCO from the SCORM course.',
params: [{ name: 'scoId', type: 'string', description: 'SCO ID', required: true }],
async handler({ scoId }) {
const course = await loadScorm()
if (!course) throw new Error('No SCORM course found')
const existing = course.scos.find((s: { id: string }) => s.id === String(scoId))
const updated = { ...course, scos: course.scos.filter((s: { id: string }) => s.id !== scoId), updatedAt: Date.now() }
await saveScorm(updated)
if (existing) {
EventBus.emit('scorm:sco:removed', { scoId: String(scoId) })
}
return { success: true }
},
},
{
name: 'scorm_update_sco',
category: 'scorm',
description: 'Update a SCO\'s title, description, or passing score override.',
params: [
{ name: 'scoId', type: 'string', description: 'SCO ID', required: true },
{ name: 'title', type: 'string', description: 'New title' },
{ name: 'description', type: 'string', description: 'Description' },
{ name: 'passingScoreOverride', type: 'number', description: 'Override passing score (null to use course default)' },
],
async handler({ scoId, ...patch }) {
const course = await loadScorm()
if (!course) throw new Error('No SCORM course found')
const updated = { ...course, scos: course.scos.map((s: { id: string }) => s.id === scoId ? { ...s, ...patch } : s), updatedAt: Date.now() }
await saveScorm(updated)
EventBus.emit('scorm:sco:updated', { scoId: String(scoId), patch })
return { success: true }
},
},
{
name: 'scorm_add_question',
category: 'scorm',
description: 'Add a question to a SCO. Types: choice | multiple | true-false | fill-in | matching | sequencing | likert | numeric | performance.',
params: [
{ name: 'scoId', type: 'string', description: 'SCO ID', required: true },
{ name: 'type', type: 'string', description: 'Question type', required: true },
{ name: 'stem', type: 'string', description: 'Question text/stem' },
{ name: 'points', type: 'number', description: 'Points value (default 10)' },
],
async handler({ scoId, type, stem, points }) {
const course = await loadScorm()
if (!course) throw new Error('No SCORM course found')
const q = {
id: `q-${uid()}`, type, stem: stem ?? '', points: points ?? 10, timeLimit: 0,
hint: '', feedback: { correct: 'Correct!', incorrect: 'Incorrect.', general: '' },
choices: (type === 'choice' || type === 'multiple') ? [{ id: 'c1', text: 'Option A' }, { id: 'c2', text: 'Option B' }] : [],
correctChoiceIds: type === 'choice' ? ['c1'] : [],
correctAnswers: [''], caseSensitive: false,
pairs: [], items: [], steps: [],
numericAnswer: 0, numericTolerance: 0, numericMin: 0, numericMax: 100,
likertPoints: 5, likertMinLabel: 'Strongly Disagree', likertMaxLabel: 'Strongly Agree',
tfCorrect: 'true',
}
const updated = {
...course,
scos: course.scos.map((s: { id: string; questions: unknown[] }) => s.id === scoId ? { ...s, questions: [...s.questions, q] } : s),
updatedAt: Date.now(),
}
await saveScorm(updated)
EventBus.emit('scorm:question:added', { scoId: String(scoId), question: q })
return { success: true, question: q }
},
},
{
name: 'scorm_remove_question',
category: 'scorm',
description: 'Remove a question from a SCO.',
params: [
{ name: 'scoId', type: 'string', description: 'SCO ID', required: true },
{ name: 'questionId', type: 'string', description: 'Question ID', required: true },
],
async handler({ scoId, questionId }) {
const course = await loadScorm()
if (!course) throw new Error('No SCORM course found')
const updated = {
...course,
scos: course.scos.map((s: { id: string; questions: { id: string }[] }) => s.id === scoId ? { ...s, questions: s.questions.filter(q => q.id !== questionId) } : s),
updatedAt: Date.now(),
}
await saveScorm(updated)
EventBus.emit('scorm:question:removed', { scoId: String(scoId), questionId: String(questionId) })
return { success: true }
},
},
{
name: 'scorm_update_settings',
category: 'scorm',
description: 'Update SCORM course settings (passing score, max attempts, shuffle, feedback, etc.).',
params: [
{ name: 'passingScore', type: 'number', description: 'Passing score 0-100' },
{ name: 'maxAttempts', type: 'number', description: 'Max attempts (-1 = unlimited)' },
{ name: 'shuffleQuestions', type: 'boolean', description: 'Shuffle question order' },
{ name: 'shuffleChoices', type: 'boolean', description: 'Shuffle choice order' },
{ name: 'showFeedback', type: 'boolean', description: 'Show feedback after each answer' },
{ name: 'completionCriteria', type: 'string', description: 'passed | completed' },
],
async handler(patch) {
const course = await loadScorm()
if (!course) throw new Error('No SCORM course found')
const updated = { ...course, settings: { ...course.settings, ...patch }, updatedAt: Date.now() }
await saveScorm(updated)
EventBus.emit('scorm:course:saved', { course: updated as any })
return { success: true, settings: updated.settings }
},
},
// ── Admin: Users ──────────────────────────────────────────────
{
name: 'admin_list_users',
category: 'admin',
description: 'List all users in the system.',
params: [],
async handler() { return await loadAdminUsers() },
},
{
name: 'admin_add_user',
category: 'admin',
description: 'Add a new user to the system.',
params: [
{ name: 'name', type: 'string', description: 'Full name', required: true },
{ name: 'email', type: 'string', description: 'Email address', required: true },
{ name: 'role', type: 'string', description: 'Role: admin | instructor | learner', required: true },
],
async handler({ name, email, role }) {
const users = await loadAdminUsers()
const initials = String(name).trim().split(' ').map((w: string) => w[0]).join('').toUpperCase().slice(0, 2)
const user = { id: `u-${uid()}`, name: String(name), email: String(email), role, status: 'pending', avatar: initials, createdAt: Date.now(), lastActive: Date.now() }
await saveAdminUsers([...users, user])
EventBus.emit('admin:user:created', { userId: user.id, role: String(role) })
return { success: true, user }
},
},
{
name: 'admin_update_user',
category: 'admin',
description: 'Update a user\'s role or status.',
params: [
{ name: 'id', type: 'string', description: 'User ID', required: true },
{ name: 'role', type: 'string', description: 'New role' },
{ name: 'status', type: 'string', description: 'New status: active | inactive | pending' },
],
async handler({ id, ...patch }) {
const users = await loadAdminUsers()
const target = users.find((u: { id: string }) => u.id === String(id))
const updated = users.map((u: { id: string }) => u.id === id ? { ...u, ...patch } : u)
await saveAdminUsers(updated)
if (target) {
EventBus.emit('admin:user:updated', { userId: String(id), patch: patch as Record<string, unknown> })
}
return { success: true }
},
},
{
name: 'admin_enroll_user',
category: 'admin',
description: 'Enroll a user in a course.',
params: [
{ name: 'userId', type: 'string', description: 'User ID', required: true },
{ name: 'courseId', type: 'string', description: 'Course ID', required: true },
{ name: 'courseTitle', type: 'string', description: 'Course title' },
],
async handler({ userId, courseId, courseTitle }) {
const enrollments = await loadEnrollments()
const enr = { id: `enr-${uid()}`, userId, courseId, courseTitle: courseTitle ?? courseId, courseType: 'cms', status: 'not_started', score: null, progress: 0, startedAt: null, completedAt: null, timeSpentMin: 0 }
await saveEnrollments([...enrollments, enr])
EventBus.emit('admin:enrolled', { userId: String(userId), courseId: String(courseId) })
return { success: true, enrollment: enr }
},
},
{
name: 'admin_get_analytics',
category: 'admin',
description: 'Get a summary of platform analytics.',
params: [],
async handler() {
const users = await loadAdminUsers()
const enrollments = await loadEnrollments()
const completed = enrollments.filter((e: { status: string }) => e.status === 'completed')
const scores = completed.filter((e: { score: number | null }) => e.score !== null).map((e: { score: number }) => e.score)
return {
totalUsers: users.length,
activeUsers: users.filter((u: { status: string }) => u.status === 'active').length,
totalEnrollments: enrollments.length,
completions: completed.length,
avgScore: scores.length ? Math.round(scores.reduce((a: number, b: number) => a + b, 0) / scores.length) : null,
completionRate: enrollments.length ? Math.round(completed.length / enrollments.length * 100) : 0,
}
},
},
]
// ── Tool executor ─────────────────────────────────────────────
export async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
const tool = TOOLS.find(t => t.name === name)
if (!tool) throw new Error(`Unknown tool: "${name}"`)
return tool.handler(args)
}
// ── Window interface ──────────────────────────────────────────
export function installWindowInterface() {
;(window as Record<string, unknown>)['__CMS_MCP__'] = {
version: '1.0',
tools: Object.fromEntries(TOOLS.map(t => [t.name, (args = {}) => callTool(t.name, args)])),
listTools: () => TOOLS.map(t => ({ name: t.name, description: t.description, category: t.category, params: t.params })),
on: (event: string, handler: Function) => EventBus.on(event as Parameters<typeof EventBus.on>[0], handler as Parameters<typeof EventBus.on>[1]),
off: (event: string, handler: Function) => EventBus.off(event as Parameters<typeof EventBus.off>[0], handler as Parameters<typeof EventBus.off>[1]),
emit: (event: string, payload: unknown) => EventBus.emit(event as Parameters<typeof EventBus.emit>[0], payload as Parameters<typeof EventBus.emit>[1]),
getLog: () => EventBus.log,
}
}

View File

@ -0,0 +1,119 @@
import * as ReactDOMServer from 'react-dom/server'
import { BLOCKS } from './components/blocks'
import { CustomBlockRenderer } from './components/CustomBlockRenderer'
import type { CanvasBlock, CustomBlockDef, DataCacheEntry, Page, ThemeConfig } from './types'
export type PageRendererProps = {
blocks: CanvasBlock[]
customBlocks: CustomBlockDef[]
theme: ThemeConfig
dataCache?: Record<string, DataCacheEntry>
}
function renderBlockTree(
block: CanvasBlock,
customBlocks: CustomBlockDef[],
theme: ThemeConfig,
dataCache: Record<string, DataCacheEntry>,
): React.ReactNode {
if (block.blockId.startsWith('custom:')) {
const customDef = customBlocks.find(candidate => `custom:${candidate.id}` === block.blockId)
if (!customDef) return null
return (
<div key={block.instanceId} style={block.styles as React.CSSProperties}>
<CustomBlockRenderer code={customDef.code} theme={theme} />
</div>
)
}
const definition = BLOCKS.find(candidate => candidate.id === block.blockId)
if (!definition) return null
const slotNodes = definition.slots
? Array.from({ length: definition.slots }, (_, index) => (
<div key={index} style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
{(block.slots?.[index] ?? []).map(child => renderBlockTree(child, customBlocks, theme, dataCache))}
</div>
))
: undefined
const blockData = definition.isDynamic
? dataCache[block.content.dataSource ?? '']?.data ?? []
: undefined
return (
<div key={block.instanceId} style={block.styles as React.CSSProperties}>
{definition.render({ ...(definition.defaultContent ?? {}), ...block.content }, slotNodes, blockData)}
</div>
)
}
export function PageRenderer({ blocks, customBlocks, theme, dataCache = {} }: PageRendererProps) {
const cssVariables = {
['--cms-bg' as string]: theme.background,
['--cms-fg' as string]: theme.foreground,
['--cms-primary' as string]: theme.primary,
['--cms-primary-fg' as string]: theme.primaryForeground,
['--cms-muted' as string]: theme.muted,
['--cms-muted-fg' as string]: theme.mutedForeground,
['--cms-border' as string]: theme.border,
['--cms-accent' as string]: theme.accent,
['--cms-radius' as string]: `${theme.radius}rem`,
['--cms-font' as string]: theme.fontFamily,
} as React.CSSProperties
return (
<main style={{ ...cssVariables, background: theme.background, color: theme.foreground, fontFamily: theme.fontFamily, minHeight: '100vh', padding: 'clamp(1rem, 4vw, 2rem)', boxSizing: 'border-box' }}>
{blocks.length === 0 ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '400px', color: theme.mutedForeground, fontSize: '0.9rem' }}>
No blocks on this page
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
{blocks.map(block => renderBlockTree(block, customBlocks, theme, dataCache))}
</div>
)}
</main>
)
}
function escapeAttribute(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
}
function metaTag(name: string, content?: string): string {
return content ? ` <meta name="${name}" content="${escapeAttribute(content)}">\n` : ''
}
function propertyTag(property: string, content?: string): string {
return content ? ` <meta property="${property}" content="${escapeAttribute(content)}">\n` : ''
}
export function generatePageHtml(page: Page, dataCache: Record<string, DataCacheEntry> = {}): string {
const title = page.seo?.title || page.name
const robots = page.seo?.robots
const robotsContent = robots
? [robots.index === false ? 'noindex' : 'index', robots.follow === false ? 'nofollow' : 'follow', robots.noarchive ? 'noarchive' : '', robots.nosnippet ? 'nosnippet' : ''].filter(Boolean).join(', ')
: ''
const body = ReactDOMServer.renderToStaticMarkup(
<PageRenderer blocks={page.blocks} customBlocks={page.customBlocks} theme={page.theme} dataCache={dataCache} />,
)
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeAttribute(title)}</title>
${metaTag('description', page.seo?.description)}${metaTag('robots', robotsContent)}${propertyTag('og:title', page.seo?.openGraph?.title || title)}${propertyTag('og:description', page.seo?.openGraph?.description || page.seo?.description)}${propertyTag('og:image', page.seo?.openGraph?.image)}${propertyTag('og:url', page.seo?.openGraph?.url)}${propertyTag('og:type', page.seo?.openGraph?.type)}${page.seo?.canonicalUrl ? ` <link rel="canonical" href="${escapeAttribute(page.seo.canonicalUrl)}">\n` : ''} <style>html, body { margin: 0; min-height: 100%; } *, *::before, *::after { box-sizing: border-box; } img, video { max-width: 100%; height: auto; }</style>
</head>
<body>
${body}
</body>
</html>
`
}

View File

@ -0,0 +1,104 @@
import type { PageSEO } from './types'
const DEFAULT_SEO: Required<Pick<PageSEO, 'openGraph' | 'robots'>> & Omit<PageSEO, 'openGraph' | 'robots'> = {
title: '',
description: '',
canonicalUrl: '',
openGraph: {
title: '',
description: '',
image: '',
url: '',
type: 'website',
},
robots: {
index: true,
follow: true,
noarchive: false,
nosnippet: false,
},
}
export function normalizePageSlug(value: string | undefined): string {
return value?.trim() ?? ''
}
export function createEmptyPageSEO(): PageSEO {
return structuredClone(DEFAULT_SEO)
}
export function normalizePageSEO(value: PageSEO | undefined): PageSEO {
return {
title: value?.title ?? DEFAULT_SEO.title,
description: value?.description ?? DEFAULT_SEO.description,
canonicalUrl: value?.canonicalUrl ?? DEFAULT_SEO.canonicalUrl,
openGraph: {
title: value?.openGraph?.title ?? DEFAULT_SEO.openGraph.title,
description: value?.openGraph?.description ?? DEFAULT_SEO.openGraph.description,
image: value?.openGraph?.image ?? DEFAULT_SEO.openGraph.image,
url: value?.openGraph?.url ?? DEFAULT_SEO.openGraph.url,
type: value?.openGraph?.type ?? DEFAULT_SEO.openGraph.type,
},
robots: {
index: value?.robots?.index ?? DEFAULT_SEO.robots.index,
follow: value?.robots?.follow ?? DEFAULT_SEO.robots.follow,
noarchive: value?.robots?.noarchive ?? DEFAULT_SEO.robots.noarchive,
nosnippet: value?.robots?.nosnippet ?? DEFAULT_SEO.robots.nosnippet,
},
}
}
export function sanitizePageSEO(value: PageSEO | undefined): PageSEO | undefined {
if (!value) {
return undefined
}
const title = value.title?.trim() ?? ''
const description = value.description?.trim() ?? ''
const canonicalUrl = value.canonicalUrl?.trim() ?? ''
const openGraph = {
title: value.openGraph?.title?.trim() ?? '',
description: value.openGraph?.description?.trim() ?? '',
image: value.openGraph?.image?.trim() ?? '',
url: value.openGraph?.url?.trim() ?? '',
type: value.openGraph?.type?.trim() ?? DEFAULT_SEO.openGraph.type,
}
const robots = {
index: value.robots?.index ?? DEFAULT_SEO.robots.index,
follow: value.robots?.follow ?? DEFAULT_SEO.robots.follow,
noarchive: value.robots?.noarchive ?? DEFAULT_SEO.robots.noarchive,
nosnippet: value.robots?.nosnippet ?? DEFAULT_SEO.robots.nosnippet,
}
const sanitized: PageSEO = {}
if (title) {
sanitized.title = title
}
if (description) {
sanitized.description = description
}
if (canonicalUrl) {
sanitized.canonicalUrl = canonicalUrl
}
if (openGraph.title || openGraph.description || openGraph.image || openGraph.url || openGraph.type !== DEFAULT_SEO.openGraph.type) {
sanitized.openGraph = {}
if (openGraph.title) sanitized.openGraph.title = openGraph.title
if (openGraph.description) sanitized.openGraph.description = openGraph.description
if (openGraph.image) sanitized.openGraph.image = openGraph.image
if (openGraph.url) sanitized.openGraph.url = openGraph.url
if (openGraph.type) sanitized.openGraph.type = openGraph.type
}
if (
robots.index !== DEFAULT_SEO.robots.index ||
robots.follow !== DEFAULT_SEO.robots.follow ||
robots.noarchive !== DEFAULT_SEO.robots.noarchive ||
robots.nosnippet !== DEFAULT_SEO.robots.nosnippet
) {
sanitized.robots = robots
}
return Object.keys(sanitized).length > 0 ? sanitized : undefined
}

View File

@ -0,0 +1,335 @@
import { useEffect, useMemo, useState } from 'react'
import {
filterAssets,
loadAdminAssets,
loadAssetAccessEvents,
loadAssetClaims,
updateAssetStatus,
type AssetAccessEvent,
type AssetClaim,
type AssetRecord,
type AssetStatus,
} from '../admin-assets'
type LoadState = 'loading' | 'ready' | 'error'
const STATUS_FILTERS: Array<{ value: 'all' | AssetStatus; label: string }> = [
{ value: 'all', label: 'All' },
{ value: 'active', label: 'Active' },
{ value: 'archived', label: 'Archived' },
{ value: 'quarantined', label: 'Quarantined' },
{ value: 'disabled', label: 'Disabled' },
{ value: 'deleted', label: 'Deleted' },
]
function statusClassName(status: AssetStatus): string {
if (status === 'active') return 'border-emerald-200 bg-emerald-50 text-emerald-700'
if (status === 'quarantined') return 'border-amber-200 bg-amber-50 text-amber-700'
if (status === 'disabled' || status === 'deleted') return 'border-rose-200 bg-rose-50 text-rose-700'
if (status === 'archived') return 'border-zinc-200 bg-zinc-100 text-zinc-700'
return 'border-zinc-200 bg-zinc-100 text-zinc-600'
}
function formatDate(value: number | null): string {
if (value === null) return 'Unknown'
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(value)
}
function claimStatusClassName(status: string): string {
const normalized = status.toLowerCase()
if (normalized.includes('resolved')) return 'text-emerald-700'
if (normalized.includes('rejected')) return 'text-zinc-700'
if (normalized.includes('review') || normalized.includes('received')) return 'text-amber-700'
if (normalized.includes('disabled')) return 'text-rose-700'
return 'text-zinc-600'
}
export function AdminAssetsPage() {
const [state, setState] = useState<LoadState>('loading')
const [error, setError] = useState('')
const [assets, setAssets] = useState<AssetRecord[]>([])
const [statusFilter, setStatusFilter] = useState<'all' | AssetStatus>('all')
const [query, setQuery] = useState('')
const [selectedAssetID, setSelectedAssetID] = useState<string | null>(null)
const [accessEvents, setAccessEvents] = useState<AssetAccessEvent[]>([])
const [claims, setClaims] = useState<AssetClaim[]>([])
const [claimsPlaceholder, setClaimsPlaceholder] = useState('')
const [loadingDetail, setLoadingDetail] = useState(false)
const loadAssets = async () => {
try {
setState('loading')
setError('')
const rows = await loadAdminAssets()
setAssets(rows)
setSelectedAssetID(current => current ?? rows[0]?.id ?? null)
setState('ready')
} catch (err) {
setAssets([])
setSelectedAssetID(null)
setState('error')
setError(err instanceof Error ? err.message : 'Failed to load asset library')
}
}
const selectedAsset = useMemo(() => {
return assets.find(asset => asset.id === selectedAssetID) ?? null
}, [assets, selectedAssetID])
const filteredAssets = useMemo(() => {
return filterAssets(assets, statusFilter, query)
}, [assets, query, statusFilter])
useEffect(() => {
void loadAssets()
}, [])
useEffect(() => {
const assetID = selectedAsset?.id
if (!assetID) {
setAccessEvents([])
setClaims([])
setClaimsPlaceholder('')
return
}
const loadDetails = async () => {
setLoadingDetail(true)
try {
const [events, claimRows] = await Promise.all([
loadAssetAccessEvents(assetID),
loadAssetClaims(assetID),
])
setAccessEvents(events)
setClaims(claimRows)
setClaimsPlaceholder(claimRows.length === 0 ? 'No copyright claims are recorded for this asset yet.' : '')
} catch {
setAccessEvents([])
setClaims([])
setClaimsPlaceholder('Copyright claims are currently unavailable for this asset.')
} finally {
setLoadingDetail(false)
}
}
void loadDetails()
}, [selectedAsset?.id])
const onStatusAction = async (asset: AssetRecord, action: 'quarantine' | 'disable' | 'restore') => {
try {
const updated = await updateAssetStatus(asset.id, action)
setAssets(current => current.map(row => row.id === updated.id ? updated : row))
setSelectedAssetID(updated.id)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update asset status')
setState('error')
}
}
if (state === 'loading') {
return (
<div className="p-6" data-testid="admin-assets-loading">
<h2 className="text-lg font-semibold text-zinc-900">Asset Library</h2>
<p className="mt-2 text-sm text-zinc-600">Loading protected assets and copyright queue...</p>
</div>
)
}
if (state === 'error') {
return (
<div className="p-6" data-testid="admin-assets-error">
<h2 className="text-lg font-semibold text-zinc-900">Asset Library</h2>
<p className="mt-2 text-sm text-red-600">{error}</p>
<button
type="button"
onClick={() => { void loadAssets() }}
className="mt-3 rounded-md border border-zinc-200 px-3 py-1.5 text-xs font-medium text-zinc-700 hover:bg-zinc-50"
>
Retry
</button>
</div>
)
}
return (
<div className="space-y-5 p-6" data-testid="admin-assets-ready">
<header>
<h2 className="text-lg font-semibold text-zinc-900">Asset Library</h2>
<p className="mt-1 text-sm text-zinc-600">
Review asset protection posture, copyright metadata, access events, and enforcement actions.
</p>
</header>
<section className="flex flex-col gap-2 xl:flex-row xl:items-center xl:justify-between">
<div className="flex flex-wrap items-center gap-2" role="tablist" aria-label="Asset status filter">
{STATUS_FILTERS.map(filter => (
<button
key={filter.value}
type="button"
onClick={() => setStatusFilter(filter.value)}
className={`rounded-full border px-3 py-1 text-xs font-medium ${statusFilter === filter.value ? 'border-zinc-900 bg-zinc-900 text-white' : 'border-zinc-200 bg-white text-zinc-700 hover:bg-zinc-50'}`}
>
{filter.label}
</button>
))}
</div>
<label className="text-xs text-zinc-600">
Search assets
<input
type="search"
value={query}
onChange={event => setQuery(event.target.value)}
placeholder="title, owner, licence, linked resource"
className="ml-2 w-full min-w-[220px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm text-zinc-900 xl:w-[320px]"
/>
</label>
</section>
<section className="grid gap-3 xl:grid-cols-[1.35fr_1fr]">
<div className="overflow-x-auto rounded-lg border border-zinc-200 bg-white" data-testid="admin-assets-table">
<table className="responsive-table min-w-full text-sm">
<thead>
<tr className="bg-zinc-50 text-left text-xs uppercase tracking-wide text-zinc-500">
<th className="px-3 py-2 font-semibold">Asset</th>
<th className="px-3 py-2 font-semibold">Status</th>
<th className="px-3 py-2 font-semibold">Visibility</th>
<th className="px-3 py-2 font-semibold">Owner</th>
<th className="px-3 py-2 font-semibold">Licence</th>
<th className="px-3 py-2 font-semibold">Protection</th>
<th className="px-3 py-2 font-semibold">Related</th>
<th className="px-3 py-2 font-semibold">Actions</th>
</tr>
</thead>
<tbody>
{filteredAssets.map(asset => (
<tr
key={asset.id}
onClick={() => setSelectedAssetID(asset.id)}
className={`cursor-pointer border-t border-zinc-100 ${selectedAsset?.id === asset.id ? 'bg-zinc-50' : 'hover:bg-zinc-50/70'}`}
>
<td data-primary="true" data-label="Asset" className="px-3 py-2">
<p className="font-medium text-zinc-900">{asset.title}</p>
<p className="text-xs text-zinc-500">{asset.id}</p>
</td>
<td data-label="Status" className="px-3 py-2">
<span className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${statusClassName(asset.status)}`}>
{asset.status}
</span>
</td>
<td data-label="Visibility" className="px-3 py-2 text-zinc-700">{asset.visibility}</td>
<td data-label="Owner" className="px-3 py-2 text-zinc-700">{asset.owner}</td>
<td data-label="Licence" className="px-3 py-2 text-zinc-700">{asset.licence}</td>
<td data-label="Protection" className="px-3 py-2 text-zinc-700">{asset.protectionLevel}</td>
<td data-label="Related" className="px-3 py-2 text-zinc-700">{asset.relatedResource}</td>
<td data-label="Actions" className="px-3 py-2">
<div className="flex flex-wrap gap-1" onClick={event => event.stopPropagation()}>
{asset.status !== 'quarantined' ? (
<button
type="button"
onClick={() => { void onStatusAction(asset, 'quarantine') }}
className="rounded-md border border-amber-200 bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700"
>
Quarantine
</button>
) : null}
{asset.status !== 'disabled' ? (
<button
type="button"
onClick={() => { void onStatusAction(asset, 'disable') }}
className="rounded-md border border-rose-200 bg-rose-50 px-2 py-0.5 text-xs font-medium text-rose-700"
>
Disable
</button>
) : null}
{asset.status !== 'active' ? (
<button
type="button"
onClick={() => { void onStatusAction(asset, 'restore') }}
className="rounded-md border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700"
>
Restore
</button>
) : null}
</div>
</td>
</tr>
))}
</tbody>
</table>
{filteredAssets.length === 0 ? (
<div className="border-t border-zinc-100 p-6 text-center text-sm text-zinc-600" data-testid="admin-assets-empty">
No assets matched this filter.
</div>
) : null}
</div>
<aside className="space-y-3 rounded-lg border border-zinc-200 bg-white p-4" data-testid="admin-assets-detail">
{selectedAsset ? (
<>
<div>
<h3 className="text-base font-semibold text-zinc-900">{selectedAsset.title}</h3>
<p className="text-xs text-zinc-500">{selectedAsset.id}</p>
</div>
<section>
<h4 className="text-xs font-semibold uppercase tracking-wide text-zinc-500">Copyright metadata</h4>
<pre className="mt-1 overflow-x-auto rounded-md border border-zinc-200 bg-zinc-50 p-2 text-xs text-zinc-700">
{JSON.stringify(selectedAsset.copyrightMetadata, null, 2)}
</pre>
</section>
<section>
<h4 className="text-xs font-semibold uppercase tracking-wide text-zinc-500">Access events</h4>
{loadingDetail ? (
<p className="mt-1 text-xs text-zinc-600">Loading asset detail...</p>
) : accessEvents.length === 0 ? (
<p className="mt-1 rounded-md border border-zinc-200 bg-zinc-50 p-2 text-xs text-zinc-600">
No access events are available for this asset.
</p>
) : (
<ul className="mt-1 space-y-1 text-xs">
{accessEvents.slice(0, 8).map(event => (
<li key={event.id} className="rounded-md border border-zinc-200 p-2">
<p className="font-medium text-zinc-900">{event.action} · {event.result}</p>
<p className="text-zinc-600">{event.actor} · {event.reason} · {formatDate(event.createdAt)}</p>
</li>
))}
</ul>
)}
</section>
<section>
<h4 className="text-xs font-semibold uppercase tracking-wide text-zinc-500">Copyright review queue</h4>
{claimsPlaceholder ? (
<p className="mt-1 rounded-md border border-zinc-200 bg-zinc-50 p-2 text-xs text-zinc-600">{claimsPlaceholder}</p>
) : claims.length === 0 ? (
<p className="mt-1 rounded-md border border-zinc-200 bg-zinc-50 p-2 text-xs text-zinc-600">
No claims are currently associated with this asset.
</p>
) : (
<ul className="mt-1 space-y-1 text-xs">
{claims.slice(0, 8).map(claim => (
<li key={claim.id} className="rounded-md border border-zinc-200 p-2">
<p className="font-medium text-zinc-900">{claim.claimType}</p>
<p className={`font-medium ${claimStatusClassName(claim.status)}`}>{claim.status}</p>
<p className="text-zinc-600">{claim.claimant} · {formatDate(claim.submittedAt)}</p>
</li>
))}
</ul>
)}
</section>
</>
) : (
<p className="text-sm text-zinc-600">Select an asset to inspect metadata, events, and claims.</p>
)}
</aside>
</section>
</div>
)
}

View File

@ -0,0 +1,312 @@
import { useEffect, useMemo, useState } from 'react'
import { filterAuditEvents, loadAuditEvents, type AuditEvent, type AuditFilters } from '../admin-audit'
type LoadState = 'loading' | 'ready' | 'error'
const INITIAL_FILTERS: AuditFilters = {
action: '',
resource: '',
actor: '',
result: '',
since: '',
until: '',
}
function formatTimestamp(value: number | null): string {
if (value === null) return 'Unknown'
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(value)
}
function resultClassName(result: AuditEvent['result']): string {
if (result === 'success') return 'border-emerald-200 bg-emerald-50 text-emerald-700'
if (result === 'failed') return 'border-rose-200 bg-rose-50 text-rose-700'
return 'border-zinc-200 bg-zinc-100 text-zinc-600'
}
function toDateTimeLocalInput(value: string): string {
if (!value) return ''
const parsed = Date.parse(value)
if (Number.isNaN(parsed)) return ''
const date = new Date(parsed)
const yyyy = date.getFullYear()
const mm = String(date.getMonth() + 1).padStart(2, '0')
const dd = String(date.getDate()).padStart(2, '0')
const hh = String(date.getHours()).padStart(2, '0')
const min = String(date.getMinutes()).padStart(2, '0')
return `${yyyy}-${mm}-${dd}T${hh}:${min}`
}
function fromDateTimeLocalInput(value: string): string {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
return date.toISOString()
}
export function AdminAuditPage() {
const [state, setState] = useState<LoadState>('loading')
const [error, setError] = useState('')
const [events, setEvents] = useState<AuditEvent[]>([])
const [filters, setFilters] = useState<AuditFilters>(INITIAL_FILTERS)
const [search, setSearch] = useState('')
const [selectedID, setSelectedID] = useState<string | null>(null)
const load = async (nextFilters: AuditFilters) => {
try {
setState('loading')
setError('')
const rows = await loadAuditEvents(nextFilters)
setEvents(rows)
setSelectedID(current => current ?? rows[0]?.id ?? null)
setState('ready')
} catch (err) {
setEvents([])
setSelectedID(null)
setState('error')
setError(err instanceof Error ? err.message : 'Failed to load audit events')
}
}
useEffect(() => {
void load(filters)
}, [])
const filteredEvents = useMemo(() => filterAuditEvents(events, search), [events, search])
const selectedEvent = useMemo(() => {
const active = filteredEvents.find(event => event.id === selectedID)
if (active) return active
return filteredEvents[0] ?? null
}, [filteredEvents, selectedID])
const onApplyFilters = () => {
void load(filters)
}
const onResetFilters = () => {
setFilters(INITIAL_FILTERS)
void load(INITIAL_FILTERS)
}
if (state === 'loading') {
return (
<div className="p-6" data-testid="admin-audit-loading">
<h2 className="text-lg font-semibold text-zinc-900">Audit Log</h2>
<p className="mt-2 text-sm text-zinc-600">Loading platform events...</p>
</div>
)
}
if (state === 'error') {
return (
<div className="p-6" data-testid="admin-audit-error">
<h2 className="text-lg font-semibold text-zinc-900">Audit Log</h2>
<p className="mt-2 text-sm text-red-600">{error}</p>
<button
type="button"
onClick={() => { void load(filters) }}
className="mt-3 rounded-md border border-zinc-200 px-3 py-1.5 text-xs font-medium text-zinc-700 hover:bg-zinc-50"
>
Retry
</button>
</div>
)
}
return (
<div className="space-y-4 p-6" data-testid="admin-audit-ready">
<header>
<h2 className="text-lg font-semibold text-zinc-900">Audit Log</h2>
<p className="mt-1 text-sm text-zinc-600">
Review key platform events with actor, action, resource, result, timestamp, and redacted metadata.
</p>
</header>
<section className="rounded-lg border border-zinc-200 bg-white p-3" data-testid="admin-audit-filters">
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-3">
<label className="text-xs text-zinc-600">
Action
<input
type="text"
value={filters.action}
onChange={event => setFilters(current => ({ ...current, action: event.target.value }))}
className="mt-1 w-full rounded-md border border-zinc-300 px-3 py-1.5 text-sm text-zinc-900"
placeholder="settings.patch"
/>
</label>
<label className="text-xs text-zinc-600">
Resource
<input
type="text"
value={filters.resource}
onChange={event => setFilters(current => ({ ...current, resource: event.target.value }))}
className="mt-1 w-full rounded-md border border-zinc-300 px-3 py-1.5 text-sm text-zinc-900"
placeholder="platform_settings"
/>
</label>
<label className="text-xs text-zinc-600">
Actor
<input
type="text"
value={filters.actor}
onChange={event => setFilters(current => ({ ...current, actor: event.target.value }))}
className="mt-1 w-full rounded-md border border-zinc-300 px-3 py-1.5 text-sm text-zinc-900"
placeholder="admin"
/>
</label>
<label className="text-xs text-zinc-600">
Result
<select
value={filters.result}
onChange={event => setFilters(current => ({ ...current, result: event.target.value as AuditFilters['result'] }))}
className="mt-1 w-full rounded-md border border-zinc-300 px-3 py-1.5 text-sm text-zinc-900"
>
<option value="">Any</option>
<option value="success">Success</option>
<option value="failed">Failed</option>
</select>
</label>
<label className="text-xs text-zinc-600">
Since
<input
type="datetime-local"
value={toDateTimeLocalInput(filters.since)}
onChange={event => setFilters(current => ({ ...current, since: fromDateTimeLocalInput(event.target.value) }))}
className="mt-1 w-full rounded-md border border-zinc-300 px-3 py-1.5 text-sm text-zinc-900"
/>
</label>
<label className="text-xs text-zinc-600">
Until
<input
type="datetime-local"
value={toDateTimeLocalInput(filters.until)}
onChange={event => setFilters(current => ({ ...current, until: fromDateTimeLocalInput(event.target.value) }))}
className="mt-1 w-full rounded-md border border-zinc-300 px-3 py-1.5 text-sm text-zinc-900"
/>
</label>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={onApplyFilters}
className="rounded-md border border-zinc-200 px-3 py-1.5 text-xs font-medium text-zinc-700 hover:bg-zinc-50"
>
Apply filters
</button>
<button
type="button"
onClick={onResetFilters}
className="rounded-md border border-zinc-200 px-3 py-1.5 text-xs font-medium text-zinc-700 hover:bg-zinc-50"
>
Reset
</button>
<label className="ml-auto text-xs text-zinc-600">
Quick search
<input
type="search"
value={search}
onChange={event => setSearch(event.target.value)}
className="ml-2 rounded-md border border-zinc-300 px-3 py-1.5 text-sm text-zinc-900"
placeholder="action, actor, resource"
/>
</label>
</div>
</section>
<section className="grid gap-3 xl:grid-cols-[1.25fr_1fr]">
<div className="overflow-x-auto rounded-lg border border-zinc-200 bg-white" data-testid="admin-audit-table">
<table className="responsive-table min-w-full text-sm">
<thead>
<tr className="bg-zinc-50 text-left text-xs uppercase tracking-wide text-zinc-500">
<th className="px-3 py-2 font-semibold">Timestamp</th>
<th className="px-3 py-2 font-semibold">Actor</th>
<th className="px-3 py-2 font-semibold">Action</th>
<th className="px-3 py-2 font-semibold">Resource</th>
<th className="px-3 py-2 font-semibold">Result</th>
</tr>
</thead>
<tbody>
{filteredEvents.map(event => (
<tr
key={event.id}
onClick={() => setSelectedID(event.id)}
className={`cursor-pointer border-t border-zinc-100 ${selectedEvent?.id === event.id ? 'bg-zinc-50' : 'hover:bg-zinc-50/70'}`}
>
<td data-primary="true" data-label="Timestamp" className="px-3 py-2 text-zinc-700">{formatTimestamp(event.timestamp)}</td>
<td data-label="Actor" className="px-3 py-2 text-zinc-700">{event.actor}</td>
<td data-label="Action" className="px-3 py-2 font-medium text-zinc-900">{event.action}</td>
<td data-label="Resource" className="px-3 py-2 text-zinc-700">{event.resource}</td>
<td data-label="Result" className="px-3 py-2">
<span className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${resultClassName(event.result)}`}>
{event.result}
</span>
</td>
</tr>
))}
</tbody>
</table>
{filteredEvents.length === 0 ? (
<div className="border-t border-zinc-100 p-6 text-center text-sm text-zinc-600" data-testid="admin-audit-empty">
No audit events matched these filters.
</div>
) : null}
</div>
<aside className="rounded-lg border border-zinc-200 bg-white p-4" data-testid="admin-audit-detail">
{selectedEvent ? (
<>
<h3 className="text-base font-semibold text-zinc-900">Event detail</h3>
<dl className="mt-2 space-y-1 text-sm text-zinc-700">
<div>
<dt className="inline font-medium text-zinc-900">ID:</dt>{' '}
<dd className="inline">{selectedEvent.id}</dd>
</div>
<div>
<dt className="inline font-medium text-zinc-900">Actor:</dt>{' '}
<dd className="inline">{selectedEvent.actor}</dd>
</div>
<div>
<dt className="inline font-medium text-zinc-900">Action:</dt>{' '}
<dd className="inline">{selectedEvent.action}</dd>
</div>
<div>
<dt className="inline font-medium text-zinc-900">Resource:</dt>{' '}
<dd className="inline">{selectedEvent.resource}</dd>
</div>
<div>
<dt className="inline font-medium text-zinc-900">Result:</dt>{' '}
<dd className="inline">{selectedEvent.result}</dd>
</div>
<div>
<dt className="inline font-medium text-zinc-900">Timestamp:</dt>{' '}
<dd className="inline">{formatTimestamp(selectedEvent.timestamp)}</dd>
</div>
</dl>
<div className="mt-3">
<h4 className="text-xs font-semibold uppercase tracking-wide text-zinc-500">Metadata</h4>
<pre className="mt-1 overflow-x-auto rounded-md border border-zinc-200 bg-zinc-50 p-2 text-xs text-zinc-700">
{JSON.stringify(selectedEvent.metadata, null, 2)}
</pre>
</div>
</>
) : (
<p className="text-sm text-zinc-600">Select an event to inspect metadata details.</p>
)}
</aside>
</section>
</div>
)
}

View File

@ -0,0 +1,420 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { BookText, Plus, Save, Trash2 } from 'lucide-react'
import {
createAdminBlogPost,
deleteAdminBlogPost,
fetchAdminBlogAuthors,
fetchAdminBlogCategories,
fetchAdminBlogPosts,
fetchAdminBlogTags,
type BlogPost,
type BlogStatus,
type BlogTaxonomy,
updateAdminBlogPost,
} from '../blog'
type EditorState = {
id?: string
title: string
slug: string
excerpt: string
content: string
contentPagePath: string
status: BlogStatus
publishedAt: string
categories: string
tags: string
authorId: string
}
const EMPTY_EDITOR: EditorState = {
title: '',
slug: '',
excerpt: '',
content: '',
contentPagePath: '',
status: 'draft',
publishedAt: '',
categories: '',
tags: '',
authorId: '',
}
function toDateTimeInput(value?: number): string {
if (!value || !Number.isFinite(value)) {
return ''
}
const date = new Date(value)
const offsetMs = date.getTimezoneOffset() * 60_000
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16)
}
function fromDateTimeInput(value: string): number | undefined {
const trimmed = value.trim()
if (!trimmed) {
return undefined
}
const timestamp = Date.parse(trimmed)
return Number.isFinite(timestamp) ? timestamp : undefined
}
function parseTokenList(value: string): string[] {
return value
.split(',')
.map(token => token.trim())
.filter(Boolean)
}
function toEditor(post: BlogPost): EditorState {
return {
id: post.id,
title: post.title,
slug: post.slug,
excerpt: post.excerpt ?? '',
content: post.content ?? '',
contentPagePath: post.contentPagePath ?? '',
status: post.status ?? 'draft',
publishedAt: toDateTimeInput(post.publishedAt),
categories: (post.categories ?? []).join(', '),
tags: (post.tags ?? []).join(', '),
authorId: post.authorId ?? '',
}
}
function formatDate(value?: number): string {
if (!value) {
return '-'
}
return new Date(value).toLocaleString()
}
export function AdminBlogPage() {
const [posts, setPosts] = useState<BlogPost[]>([])
const [categories, setCategories] = useState<BlogTaxonomy[]>([])
const [tags, setTags] = useState<BlogTaxonomy[]>([])
const [authors, setAuthors] = useState<BlogTaxonomy[]>([])
const [selectedID, setSelectedID] = useState<string | null>(null)
const [editor, setEditor] = useState<EditorState>(EMPTY_EDITOR)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [notice, setNotice] = useState<string | null>(null)
const sortedPosts = useMemo(
() => [...posts].sort((a, b) => (b.updatedAt ?? b.createdAt ?? 0) - (a.updatedAt ?? a.createdAt ?? 0)),
[posts],
)
const loadAll = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [postsResult, categoriesResult, tagsResult, authorsResult] = await Promise.all([
fetchAdminBlogPosts(),
fetchAdminBlogCategories(),
fetchAdminBlogTags(),
fetchAdminBlogAuthors(),
])
setPosts(postsResult)
setCategories(categoriesResult)
setTags(tagsResult)
setAuthors(authorsResult)
if (selectedID) {
const current = postsResult.find(post => post.id === selectedID)
if (current) {
setEditor(toEditor(current))
}
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load blog administration data')
} finally {
setLoading(false)
}
}, [selectedID])
useEffect(() => {
void loadAll()
}, [loadAll])
const selectPost = (post: BlogPost) => {
setSelectedID(post.id)
setEditor(toEditor(post))
setNotice(null)
setError(null)
}
const newPost = () => {
setSelectedID(null)
setEditor(EMPTY_EDITOR)
setNotice(null)
setError(null)
}
const savePost = async () => {
const title = editor.title.trim()
if (!title) {
setError('Title is required')
return
}
setSaving(true)
setError(null)
setNotice(null)
const payload = {
title,
slug: editor.slug.trim() || undefined,
excerpt: editor.excerpt.trim() || undefined,
content: editor.content.trim() || undefined,
contentPagePath: editor.contentPagePath.trim() || undefined,
status: editor.status,
publishedAt: fromDateTimeInput(editor.publishedAt),
categories: parseTokenList(editor.categories),
tags: parseTokenList(editor.tags),
authorId: editor.authorId.trim() || undefined,
}
try {
if (editor.id) {
const updated = await updateAdminBlogPost(editor.id, payload)
setNotice('Post updated')
setSelectedID(updated.id)
} else {
const created = await createAdminBlogPost(payload)
setNotice('Post created')
setSelectedID(created.id)
}
await loadAll()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save blog post')
} finally {
setSaving(false)
}
}
const removePost = async () => {
if (!selectedID) {
return
}
setSaving(true)
setError(null)
setNotice(null)
try {
await deleteAdminBlogPost(selectedID)
setNotice('Post deleted')
setSelectedID(null)
setEditor(EMPTY_EDITOR)
await loadAll()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete blog post')
} finally {
setSaving(false)
}
}
return (
<main className="min-h-0 p-4 md:p-6">
<div className="mx-auto grid max-w-7xl gap-4 lg:grid-cols-[320px_1fr]">
<aside className="rounded-xl border border-zinc-200 bg-white shadow-sm">
<div className="flex items-center justify-between border-b border-zinc-200 px-4 py-3">
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-900">
<BookText size={16} />
Blog Posts
</div>
<button
type="button"
onClick={newPost}
className="inline-flex items-center gap-1 rounded-md border border-zinc-200 px-2 py-1 text-xs font-medium text-zinc-700 hover:bg-zinc-50"
>
<Plus size={14} />
New
</button>
</div>
{loading ? (
<p className="px-4 py-6 text-sm text-zinc-500">Loading posts...</p>
) : sortedPosts.length === 0 ? (
<p className="px-4 py-6 text-sm text-zinc-500">No posts yet. Create your first post.</p>
) : (
<ul className="max-h-[70vh] overflow-auto p-2">
{sortedPosts.map(post => (
<li key={post.id}>
<button
type="button"
onClick={() => selectPost(post)}
className={`w-full rounded-md px-3 py-2 text-left ${selectedID === post.id ? 'bg-zinc-900 text-white' : 'text-zinc-800 hover:bg-zinc-100'}`}
>
<p className="truncate text-sm font-medium">{post.title || post.id}</p>
<p className={`text-xs ${selectedID === post.id ? 'text-zinc-200' : 'text-zinc-500'}`}>
{(post.status ?? 'draft')} · updated {formatDate(post.updatedAt ?? post.createdAt)}
</p>
</button>
</li>
))}
</ul>
)}
</aside>
<section className="rounded-xl border border-zinc-200 bg-white shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-zinc-200 px-4 py-3">
<h1 className="text-sm font-semibold text-zinc-900">Blog Administration</h1>
<div className="flex items-center gap-2">
{selectedID ? (
<button
type="button"
onClick={() => void removePost()}
disabled={saving}
className="inline-flex items-center gap-1 rounded-md border border-red-200 px-2.5 py-1.5 text-xs font-medium text-red-700 hover:bg-red-50 disabled:opacity-50"
>
<Trash2 size={14} />
Delete
</button>
) : null}
<button
type="button"
onClick={() => void savePost()}
disabled={saving}
className="inline-flex items-center gap-1 rounded-md bg-zinc-900 px-2.5 py-1.5 text-xs font-medium text-white hover:bg-zinc-800 disabled:opacity-50"
>
<Save size={14} />
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
<div className="space-y-4 p-4">
{error ? <p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p> : null}
{notice ? <p className="rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{notice}</p> : null}
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1 text-xs font-medium text-zinc-600">
Title
<input
value={editor.title}
onChange={event => setEditor(prev => ({ ...prev, title: event.target.value }))}
placeholder="Platform update"
className="w-full rounded-md border border-zinc-200 px-3 py-2 text-sm text-zinc-900"
/>
</label>
<label className="space-y-1 text-xs font-medium text-zinc-600">
Slug
<input
value={editor.slug}
onChange={event => setEditor(prev => ({ ...prev, slug: event.target.value }))}
placeholder="platform-update"
className="w-full rounded-md border border-zinc-200 px-3 py-2 text-sm text-zinc-900"
/>
</label>
<label className="space-y-1 text-xs font-medium text-zinc-600">
Status
<select
value={editor.status}
onChange={event => setEditor(prev => ({ ...prev, status: event.target.value as BlogStatus }))}
className="w-full rounded-md border border-zinc-200 px-3 py-2 text-sm text-zinc-900"
>
<option value="draft">Draft</option>
<option value="scheduled">Scheduled</option>
<option value="published">Published</option>
</select>
</label>
<label className="space-y-1 text-xs font-medium text-zinc-600">
Published At
<input
type="datetime-local"
value={editor.publishedAt}
onChange={event => setEditor(prev => ({ ...prev, publishedAt: event.target.value }))}
className="w-full rounded-md border border-zinc-200 px-3 py-2 text-sm text-zinc-900"
/>
</label>
<label className="space-y-1 text-xs font-medium text-zinc-600 md:col-span-2">
Excerpt
<textarea
value={editor.excerpt}
onChange={event => setEditor(prev => ({ ...prev, excerpt: event.target.value }))}
rows={3}
className="w-full rounded-md border border-zinc-200 px-3 py-2 text-sm text-zinc-900"
/>
</label>
<label className="space-y-1 text-xs font-medium text-zinc-600 md:col-span-2">
Content
<textarea
value={editor.content}
onChange={event => setEditor(prev => ({ ...prev, content: event.target.value }))}
rows={10}
className="w-full rounded-md border border-zinc-200 px-3 py-2 text-sm text-zinc-900"
/>
</label>
<label className="space-y-1 text-xs font-medium text-zinc-600 md:col-span-2">
Linked content page path
<input
value={editor.contentPagePath}
onChange={event => setEditor(prev => ({ ...prev, contentPagePath: event.target.value }))}
placeholder="/home"
className="w-full rounded-md border border-zinc-200 px-3 py-2 text-sm text-zinc-900"
/>
</label>
<label className="space-y-1 text-xs font-medium text-zinc-600">
Categories (comma-separated)
<input
value={editor.categories}
onChange={event => setEditor(prev => ({ ...prev, categories: event.target.value }))}
placeholder="news, release"
className="w-full rounded-md border border-zinc-200 px-3 py-2 text-sm text-zinc-900"
/>
</label>
<label className="space-y-1 text-xs font-medium text-zinc-600">
Tags (comma-separated)
<input
value={editor.tags}
onChange={event => setEditor(prev => ({ ...prev, tags: event.target.value }))}
placeholder="platform, launch"
className="w-full rounded-md border border-zinc-200 px-3 py-2 text-sm text-zinc-900"
/>
</label>
<label className="space-y-1 text-xs font-medium text-zinc-600 md:col-span-2">
Author
<select
value={editor.authorId}
onChange={event => setEditor(prev => ({ ...prev, authorId: event.target.value }))}
className="w-full rounded-md border border-zinc-200 px-3 py-2 text-sm text-zinc-900"
>
<option value="">Unassigned</option>
{authors.map(author => (
<option key={author.id || author.name} value={author.id || author.name}>
{author.name}
</option>
))}
</select>
</label>
</div>
<section className="grid gap-3 rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-xs text-zinc-600 md:grid-cols-2">
<div>
<p className="font-semibold text-zinc-800">Known categories</p>
<p className="mt-1">{categories.map(item => item.name).join(', ') || 'No categories available yet.'}</p>
</div>
<div>
<p className="font-semibold text-zinc-800">Known tags</p>
<p className="mt-1">{tags.map(item => item.name).join(', ') || 'No tags available yet.'}</p>
</div>
</section>
</div>
</section>
</div>
</main>
)
}

View File

@ -0,0 +1,233 @@
import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router'
import {
filterContentRecords,
loadAdminContentData,
type AdminContentData,
type AdminContentRecord,
type ContentPublishStatus,
} from '../admin-content'
type LoadState = 'loading' | 'ready' | 'error'
type StatusFilter = 'all' | Exclude<ContentPublishStatus, 'unknown'>
const STATUS_FILTERS: Array<{ value: StatusFilter; label: string }> = [
{ value: 'all', label: 'All' },
{ value: 'draft', label: 'Draft' },
{ value: 'published', label: 'Published' },
{ value: 'scheduled', label: 'Scheduled' },
{ value: 'archived', label: 'Archived' },
]
const QUICK_LINKS = [
{ title: 'CMS Builder', summary: 'Edit site pages and publishing metadata.', to: '/admin/builder' },
{ title: 'Blog', summary: 'Manage blog posts and publishing schedule.', to: '/admin/blog' },
{ title: 'Forms', summary: 'Update forms and review submission flow.', to: '/admin/forms' },
{ title: 'Assets', summary: 'Check uploads and rights protection posture.', to: '/admin/assets' },
]
function formatUpdatedDate(value: number | null): string {
if (value === null) return 'Unknown'
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(value)
}
function titleForType(type: AdminContentRecord['type']): string {
if (type === 'page') return 'Page'
if (type === 'blog') return 'Blog'
if (type === 'form') return 'Form'
return 'Asset'
}
function statusClassName(status: ContentPublishStatus): string {
if (status === 'published') return 'bg-emerald-50 text-emerald-700 border-emerald-200'
if (status === 'draft') return 'bg-zinc-100 text-zinc-700 border-zinc-200'
if (status === 'scheduled') return 'bg-blue-50 text-blue-700 border-blue-200'
if (status === 'archived') return 'bg-amber-50 text-amber-700 border-amber-200'
return 'bg-zinc-100 text-zinc-600 border-zinc-200'
}
function seoClassName(seo: AdminContentRecord['seo']): string {
if (seo === 'ready') return 'bg-emerald-50 text-emerald-700 border-emerald-200'
if (seo === 'missing') return 'bg-amber-50 text-amber-700 border-amber-200'
return 'bg-zinc-100 text-zinc-600 border-zinc-200'
}
function rightsClassName(rights: AdminContentRecord['rights']): string {
if (rights === 'protected') return 'bg-emerald-50 text-emerald-700 border-emerald-200'
if (rights === 'review') return 'bg-rose-50 text-rose-700 border-rose-200'
return 'bg-zinc-100 text-zinc-600 border-zinc-200'
}
export function AdminContentPage() {
const [state, setState] = useState<LoadState>('loading')
const [data, setData] = useState<AdminContentData | null>(null)
const [error, setError] = useState('')
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
const [query, setQuery] = useState('')
const load = async () => {
try {
setState('loading')
setError('')
const next = await loadAdminContentData()
setData(next)
setState('ready')
} catch (err) {
setData(null)
setState('error')
setError(err instanceof Error ? err.message : 'Failed to load content management data')
}
}
useEffect(() => {
void load()
}, [])
const filteredRecords = useMemo(() => {
return filterContentRecords(data?.records ?? [], statusFilter, query)
}, [data?.records, query, statusFilter])
if (state === 'loading') {
return (
<div className="p-6" data-testid="admin-content-loading">
<h2 className="text-lg font-semibold text-zinc-900">Content Management</h2>
<p className="mt-2 text-sm text-zinc-600">Loading resources and publish state...</p>
</div>
)
}
if (state === 'error') {
return (
<div className="p-6" data-testid="admin-content-error">
<h2 className="text-lg font-semibold text-zinc-900">Content Management</h2>
<p className="mt-2 text-sm text-red-600">{error}</p>
<button
type="button"
onClick={() => { void load() }}
className="mt-3 rounded-md border border-zinc-200 px-3 py-1.5 text-xs font-medium text-zinc-700 hover:bg-zinc-50"
>
Retry
</button>
</div>
)
}
return (
<div className="space-y-6 p-6" data-testid="admin-content-ready">
<header>
<h2 className="text-lg font-semibold text-zinc-900">Content Management</h2>
<p className="mt-1 text-sm text-zinc-600">
Unified view for pages, blog posts, forms, and assets with publish-state, SEO, and copyright visibility.
</p>
</header>
{data?.loadWarnings.length ? (
<section className="rounded-lg border border-amber-200 bg-amber-50 p-3" data-testid="admin-content-warnings">
<h3 className="text-xs font-semibold uppercase tracking-wide text-amber-800">Data warnings</h3>
<ul className="mt-2 space-y-1 text-xs text-amber-700">
{data.loadWarnings.map(warning => (
<li key={warning}>{warning}</li>
))}
</ul>
</section>
) : null}
<section className="grid gap-3 md:grid-cols-2 xl:grid-cols-4" data-testid="admin-content-quick-links">
{QUICK_LINKS.map(link => (
<Link key={link.to} to={link.to} className="rounded-lg border border-zinc-200 bg-white p-3 hover:bg-zinc-50">
<p className="text-sm font-medium text-zinc-900">{link.title}</p>
<p className="mt-1 text-xs text-zinc-600">{link.summary}</p>
</Link>
))}
</section>
<section className="space-y-3">
<div className="flex flex-col gap-2 xl:flex-row xl:items-center xl:justify-between">
<div className="flex items-center gap-2" role="tablist" aria-label="Content status filter">
{STATUS_FILTERS.map(filter => (
<button
key={filter.value}
type="button"
onClick={() => setStatusFilter(filter.value)}
className={`rounded-full border px-3 py-1 text-xs font-medium transition-colors ${statusFilter === filter.value ? 'border-zinc-900 bg-zinc-900 text-white' : 'border-zinc-200 bg-white text-zinc-700 hover:bg-zinc-50'}`}
data-testid={`admin-content-filter-${filter.value}`}
>
{filter.label}
</button>
))}
</div>
<label className="flex items-center gap-2 text-xs text-zinc-600">
Search
<input
type="search"
value={query}
onChange={event => setQuery(event.target.value)}
placeholder="Search by title, type, or owner"
className="w-full min-w-[220px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm text-zinc-900 outline-none focus:border-zinc-500 xl:w-[320px]"
data-testid="admin-content-search"
/>
</label>
</div>
<div className="overflow-x-auto rounded-lg border border-zinc-200 bg-white" data-testid="admin-content-table">
<table className="responsive-table min-w-full text-sm">
<thead>
<tr className="bg-zinc-50 text-left text-xs uppercase tracking-wide text-zinc-500">
<th className="px-3 py-2 font-semibold">Resource</th>
<th className="px-3 py-2 font-semibold">Status</th>
<th className="px-3 py-2 font-semibold">SEO</th>
<th className="px-3 py-2 font-semibold">Rights</th>
<th className="px-3 py-2 font-semibold">Owner</th>
<th className="px-3 py-2 font-semibold">Updated</th>
<th className="px-3 py-2 font-semibold">Action</th>
</tr>
</thead>
<tbody>
{filteredRecords.map(record => (
<tr key={`${record.type}-${record.id}`} className="border-t border-zinc-100">
<td data-primary="true" data-label="Resource" className="px-3 py-2">
<p className="font-medium text-zinc-900">{record.title}</p>
<p className="text-xs text-zinc-500">{titleForType(record.type)}</p>
</td>
<td data-label="Status" className="px-3 py-2">
<span className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${statusClassName(record.status)}`}>
{record.status}
</span>
</td>
<td data-label="SEO" className="px-3 py-2">
<span className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${seoClassName(record.seo)}`}>
{record.seo}
</span>
</td>
<td data-label="Rights" className="px-3 py-2">
<span className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${rightsClassName(record.rights)}`}>
{record.rights}
</span>
</td>
<td data-label="Owner" className="px-3 py-2 text-zinc-700">{record.owner ?? 'Unknown'}</td>
<td data-label="Updated" className="px-3 py-2 text-zinc-700">{formatUpdatedDate(record.updatedAt)}</td>
<td data-label="Action" className="px-3 py-2">
<Link to={record.to} className="text-xs font-medium text-zinc-700 underline-offset-2 hover:text-zinc-900 hover:underline">
Open editor
</Link>
</td>
</tr>
))}
</tbody>
</table>
{filteredRecords.length === 0 ? (
<div className="border-t border-zinc-100 p-6 text-center text-sm text-zinc-600" data-testid="admin-content-empty">
No resources matched this filter. Try a different status filter or search term.
</div>
) : null}
</div>
</section>
</div>
)
}

View File

@ -0,0 +1,110 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router'
import { loadAdminDashboardData, type AdminDashboardData } from '../admin-dashboard'
type LoadState = 'loading' | 'ready' | 'error'
export function AdminDashboardPage() {
const [state, setState] = useState<LoadState>('loading')
const [data, setData] = useState<AdminDashboardData | null>(null)
const [error, setError] = useState('')
const load = async () => {
try {
setState('loading')
setError('')
const next = await loadAdminDashboardData()
setData(next)
setState('ready')
} catch (err) {
setData(null)
setState('error')
setError(err instanceof Error ? err.message : 'Failed to load dashboard data')
}
}
useEffect(() => {
void load()
}, [])
if (state === 'loading') {
return (
<div className="p-6" data-testid="admin-dashboard-loading">
<h2 className="text-lg font-semibold text-zinc-900">Admin Dashboard</h2>
<p className="mt-2 text-sm text-zinc-600">Loading overview metrics...</p>
</div>
)
}
if (state === 'error') {
return (
<div className="p-6" data-testid="admin-dashboard-error">
<h2 className="text-lg font-semibold text-zinc-900">Admin Dashboard</h2>
<p className="mt-2 text-sm text-red-600">{error}</p>
<button
type="button"
onClick={() => { void load() }}
className="mt-3 rounded-md border border-zinc-200 px-3 py-1.5 text-xs font-medium text-zinc-700 hover:bg-zinc-50"
>
Retry
</button>
</div>
)
}
return (
<div className="space-y-5 p-6" data-testid="admin-dashboard-ready">
<header>
<h2 className="text-lg font-semibold text-zinc-900">Admin Dashboard</h2>
<p className="mt-1 text-sm text-zinc-600">
Overview of publishing activity, learner operations, and setup health warnings.
</p>
</header>
{data?.loadWarnings.length ? (
<section className="rounded-lg border border-amber-200 bg-amber-50 p-3">
<h3 className="text-xs font-semibold uppercase tracking-wide text-amber-800">Data warnings</h3>
<ul className="mt-2 space-y-1 text-xs text-amber-700">
{data.loadWarnings.map(warning => (
<li key={warning}>{warning}</li>
))}
</ul>
</section>
) : null}
<section>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-zinc-500">Platform summary</h3>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{data?.counts.map(metric => (
<Link
key={metric.key}
to={metric.to}
className="rounded-lg border border-zinc-200 bg-white p-3 transition-colors hover:bg-zinc-50"
>
<p className="text-xs text-zinc-500">{metric.label}</p>
<p className="mt-1 text-xl font-semibold text-zinc-900">
{metric.value === null ? '-' : metric.value}
</p>
{metric.value === null && metric.placeholder ? (
<p className="mt-1 text-[11px] text-zinc-500">{metric.placeholder}</p>
) : null}
</Link>
))}
</div>
</section>
<section>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-zinc-500">Setup warnings</h3>
<div className="space-y-2">
{data?.setupWarnings.map(warning => (
<div key={warning.key} className="rounded-lg border border-red-200 bg-red-50 p-3">
<p className="text-sm font-medium text-red-800">{warning.title}</p>
<p className="mt-1 text-xs text-red-700">{warning.message}</p>
</div>
))}
</div>
</section>
</div>
)
}

View File

@ -0,0 +1,339 @@
import { useEffect, useMemo, useState } from 'react'
import {
filterLearners,
loadAdminLearnerData,
progressSummary,
type AdminLearnerData,
type LearnerEnrollment,
type LearnerProfile,
type LearnerRole,
type LearnerUserStatus,
} from '../admin-learners'
type LoadState = 'loading' | 'ready' | 'error'
type StatusFilter = 'all' | LearnerUserStatus
type RoleFilter = 'all' | LearnerRole
const STATUS_FILTERS: Array<{ value: StatusFilter; label: string }> = [
{ value: 'all', label: 'All statuses' },
{ value: 'active', label: 'Active' },
{ value: 'pending', label: 'Pending' },
{ value: 'inactive', label: 'Inactive' },
{ value: 'unknown', label: 'Unknown' },
]
const ROLE_FILTERS: Array<{ value: RoleFilter; label: string }> = [
{ value: 'all', label: 'All roles' },
{ value: 'learner', label: 'Learner' },
{ value: 'instructor', label: 'Instructor' },
{ value: 'editor', label: 'Editor' },
{ value: 'admin', label: 'Admin' },
{ value: 'unknown', label: 'Unknown' },
]
function roleClassName(role: LearnerRole): string {
if (role === 'learner') return 'border-zinc-200 bg-zinc-100 text-zinc-700'
if (role === 'instructor') return 'border-blue-200 bg-blue-50 text-blue-700'
if (role === 'editor') return 'border-purple-200 bg-purple-50 text-purple-700'
if (role === 'admin') return 'border-emerald-200 bg-emerald-50 text-emerald-700'
return 'border-zinc-200 bg-zinc-100 text-zinc-500'
}
function statusClassName(status: LearnerUserStatus): string {
if (status === 'active') return 'border-emerald-200 bg-emerald-50 text-emerald-700'
if (status === 'pending') return 'border-amber-200 bg-amber-50 text-amber-700'
if (status === 'inactive') return 'border-zinc-200 bg-zinc-100 text-zinc-600'
return 'border-zinc-200 bg-zinc-100 text-zinc-500'
}
function enrollmentStatusClassName(status: LearnerEnrollment['status']): string {
if (status === 'completed') return 'border-emerald-200 bg-emerald-50 text-emerald-700'
if (status === 'in_progress') return 'border-blue-200 bg-blue-50 text-blue-700'
if (status === 'failed') return 'border-rose-200 bg-rose-50 text-rose-700'
if (status === 'not_started') return 'border-zinc-200 bg-zinc-100 text-zinc-600'
return 'border-zinc-200 bg-zinc-100 text-zinc-500'
}
function formatDate(value: number | null): string {
if (value === null) return 'Not available'
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(value)
}
function totalTrackedProgress(enrollments: LearnerEnrollment[]): number | null {
const tracked = enrollments.filter(enrollment => enrollment.progress !== null)
if (tracked.length === 0) return null
const sum = tracked.reduce((acc, enrollment) => acc + (enrollment.progress ?? 0), 0)
return Math.round(sum / tracked.length)
}
export function AdminLearnersPage() {
const [state, setState] = useState<LoadState>('loading')
const [data, setData] = useState<AdminLearnerData | null>(null)
const [error, setError] = useState('')
const [query, setQuery] = useState('')
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
const [roleFilter, setRoleFilter] = useState<RoleFilter>('all')
const [selectedLearnerID, setSelectedLearnerID] = useState<string | null>(null)
const load = async () => {
try {
setState('loading')
setError('')
const next = await loadAdminLearnerData()
setData(next)
setSelectedLearnerID(prev => prev ?? next.learners[0]?.id ?? null)
setState('ready')
} catch (err) {
setData(null)
setState('error')
setError(err instanceof Error ? err.message : 'Failed to load learner management data')
}
}
useEffect(() => {
void load()
}, [])
const filteredLearners = useMemo(() => {
return filterLearners(data?.learners ?? [], query, statusFilter, roleFilter)
}, [data?.learners, query, roleFilter, statusFilter])
const selectedLearner = useMemo(() => {
const selectedFromFiltered = filteredLearners.find(learner => learner.id === selectedLearnerID)
if (selectedFromFiltered) return selectedFromFiltered
return filteredLearners[0] ?? null
}, [filteredLearners, selectedLearnerID])
if (state === 'loading') {
return (
<div className="p-6" data-testid="admin-learners-loading">
<h2 className="text-lg font-semibold text-zinc-900">Learner Management</h2>
<p className="mt-2 text-sm text-zinc-600">Loading learners and enrollment records...</p>
</div>
)
}
if (state === 'error') {
return (
<div className="p-6" data-testid="admin-learners-error">
<h2 className="text-lg font-semibold text-zinc-900">Learner Management</h2>
<p className="mt-2 text-sm text-red-600">{error}</p>
<button
type="button"
onClick={() => { void load() }}
className="mt-3 rounded-md border border-zinc-200 px-3 py-1.5 text-xs font-medium text-zinc-700 hover:bg-zinc-50"
>
Retry
</button>
</div>
)
}
return (
<div className="space-y-6 p-6" data-testid="admin-learners-ready">
<header>
<h2 className="text-lg font-semibold text-zinc-900">Learner Management</h2>
<p className="mt-1 text-sm text-zinc-600">
Review learner profiles, enrollment records, and role/status context. Detailed session-level activity tracking is shown as not yet tracked when unavailable.
</p>
</header>
{data?.loadWarnings.length ? (
<section className="rounded-lg border border-amber-200 bg-amber-50 p-3" data-testid="admin-learners-warnings">
<h3 className="text-xs font-semibold uppercase tracking-wide text-amber-800">Data warnings</h3>
<ul className="mt-2 space-y-1 text-xs text-amber-700">
{data.loadWarnings.map(warning => (
<li key={warning}>{warning}</li>
))}
</ul>
</section>
) : null}
<section className="grid gap-3 sm:grid-cols-3" data-testid="admin-learners-summary-cards">
<div className="rounded-lg border border-zinc-200 bg-white p-3">
<p className="text-xs uppercase tracking-wide text-zinc-500">Learners</p>
<p className="mt-1 text-2xl font-semibold text-zinc-900">{data?.learners.length ?? 0}</p>
</div>
<div className="rounded-lg border border-zinc-200 bg-white p-3">
<p className="text-xs uppercase tracking-wide text-zinc-500">Enrollments</p>
<p className="mt-1 text-2xl font-semibold text-zinc-900">
{(data?.learners ?? []).reduce((acc, learner) => acc + learner.enrollments.length, 0)}
</p>
</div>
<div className="rounded-lg border border-zinc-200 bg-white p-3">
<p className="text-xs uppercase tracking-wide text-zinc-500">Tracked progress</p>
<p className="mt-1 text-2xl font-semibold text-zinc-900">
{(() => {
const values = (data?.learners ?? [])
.flatMap(learner => learner.enrollments.map(enrollment => enrollment.progress))
.filter((value): value is number => value !== null)
if (values.length === 0) return 'Not yet tracked'
const avg = Math.round(values.reduce((acc, value) => acc + value, 0) / values.length)
return `${avg}%`
})()}
</p>
</div>
</section>
<section className="space-y-3">
<div className="flex flex-col gap-2 xl:flex-row xl:items-center xl:justify-between">
<label className="text-xs font-medium text-zinc-600">
Search learners
<input
type="search"
value={query}
onChange={event => setQuery(event.target.value)}
placeholder="Name, email, or role"
className="mt-1 w-full min-w-[220px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm text-zinc-900 outline-none focus:border-zinc-500 xl:w-[320px]"
data-testid="admin-learners-search"
/>
</label>
<div className="flex flex-wrap items-end gap-2">
<label className="text-xs font-medium text-zinc-600">
Status
<select
value={statusFilter}
onChange={event => setStatusFilter(event.target.value as StatusFilter)}
className="mt-1 rounded-md border border-zinc-300 px-2 py-1.5 text-sm text-zinc-900"
data-testid="admin-learners-status-filter"
>
{STATUS_FILTERS.map(filter => (
<option key={filter.value} value={filter.value}>{filter.label}</option>
))}
</select>
</label>
<label className="text-xs font-medium text-zinc-600">
Role
<select
value={roleFilter}
onChange={event => setRoleFilter(event.target.value as RoleFilter)}
className="mt-1 rounded-md border border-zinc-300 px-2 py-1.5 text-sm text-zinc-900"
data-testid="admin-learners-role-filter"
>
{ROLE_FILTERS.map(filter => (
<option key={filter.value} value={filter.value}>{filter.label}</option>
))}
</select>
</label>
</div>
</div>
<div className="grid gap-3 xl:grid-cols-[1.3fr_1fr]">
<div className="overflow-x-auto rounded-lg border border-zinc-200 bg-white" data-testid="admin-learners-table">
<table className="responsive-table min-w-full text-sm">
<thead>
<tr className="bg-zinc-50 text-left text-xs uppercase tracking-wide text-zinc-500">
<th className="px-3 py-2 font-semibold">Learner</th>
<th className="px-3 py-2 font-semibold">Status</th>
<th className="px-3 py-2 font-semibold">Roles</th>
<th className="px-3 py-2 font-semibold">Enrollments</th>
<th className="px-3 py-2 font-semibold">Last active</th>
</tr>
</thead>
<tbody>
{filteredLearners.map(learner => (
<tr
key={learner.id}
onClick={() => setSelectedLearnerID(learner.id)}
className={`cursor-pointer border-t border-zinc-100 ${selectedLearner?.id === learner.id ? 'bg-zinc-50' : 'hover:bg-zinc-50/70'}`}
data-testid={`admin-learners-row-${learner.id}`}
>
<td data-primary="true" data-label="Learner" className="px-3 py-2">
<p className="font-medium text-zinc-900">{learner.displayName}</p>
<p className="text-xs text-zinc-500">{learner.email || 'Email not provided'}</p>
</td>
<td data-label="Status" className="px-3 py-2">
<span className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${statusClassName(learner.status)}`}>
{learner.status}
</span>
</td>
<td data-label="Roles" className="px-3 py-2">
<div className="flex flex-wrap gap-1">
{learner.roles.map(role => (
<span key={`${learner.id}-${role}`} className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${roleClassName(role)}`}>
{role}
</span>
))}
</div>
</td>
<td data-label="Enrollments" className="px-3 py-2 text-zinc-700">{learner.enrollments.length}</td>
<td data-label="Last active" className="px-3 py-2 text-zinc-700">{formatDate(learner.lastActiveAt)}</td>
</tr>
))}
</tbody>
</table>
{filteredLearners.length === 0 ? (
<div className="border-t border-zinc-100 p-6 text-center text-sm text-zinc-600" data-testid="admin-learners-empty">
No learners matched this search/filter combination.
</div>
) : null}
</div>
<aside className="space-y-3 rounded-lg border border-zinc-200 bg-white p-4" data-testid="admin-learner-detail">
{selectedLearner ? (
<>
<div>
<h3 className="text-base font-semibold text-zinc-900">{selectedLearner.displayName}</h3>
<p className="text-sm text-zinc-600">{selectedLearner.email || 'Email not provided'}</p>
</div>
<div className="space-y-1 text-sm text-zinc-700">
<p><span className="font-medium text-zinc-900">Status:</span> {selectedLearner.status}</p>
<p><span className="font-medium text-zinc-900">Roles:</span> {selectedLearner.roles.join(', ')}</p>
<p><span className="font-medium text-zinc-900">Created:</span> {formatDate(selectedLearner.createdAt)}</p>
<p><span className="font-medium text-zinc-900">Last active:</span> {formatDate(selectedLearner.lastActiveAt)}</p>
</div>
<div>
<h4 className="text-xs font-semibold uppercase tracking-wide text-zinc-500">Enrollment records</h4>
<div className="mt-2 space-y-2">
{selectedLearner.enrollments.length === 0 ? (
<p className="rounded-md border border-zinc-200 bg-zinc-50 p-2 text-xs text-zinc-600">
No enrollment records available for this learner.
</p>
) : selectedLearner.enrollments.map(enrollment => {
const progress = progressSummary(enrollment)
return (
<article key={enrollment.id} className="rounded-md border border-zinc-200 p-2">
<p className="text-sm font-medium text-zinc-900">{enrollment.courseTitle}</p>
<div className="mt-1 flex flex-wrap items-center gap-1">
<span className={`inline-flex rounded-full border px-2 py-0.5 text-[11px] font-medium ${enrollmentStatusClassName(enrollment.status)}`}>
{enrollment.status}
</span>
<span className="inline-flex rounded-full border border-zinc-200 bg-zinc-100 px-2 py-0.5 text-[11px] font-medium text-zinc-600">
{enrollment.courseType}
</span>
</div>
<p className="mt-1 text-xs text-zinc-600">
Progress: {progress.label}
{progress.value === null ? ' (detailed module/session progress not yet tracked)' : ''}
</p>
<p className="text-xs text-zinc-600">
Activity: {enrollment.timeSpentMin === null ? 'Not yet tracked' : `${enrollment.timeSpentMin} minutes logged`}
</p>
</article>
)
})}
</div>
</div>
<div className="rounded-md border border-blue-200 bg-blue-50 p-2 text-xs text-blue-700">
Detailed lesson and session activity is not available for this learner yet.
</div>
</>
) : (
<p className="text-sm text-zinc-600">Select a learner to view profile, roles, and enrollment details.</p>
)}
</aside>
</div>
</section>
</div>
)
}

View File

@ -0,0 +1,866 @@
import React, { useState, useMemo, useEffect } from 'react'
import { Link } from 'react-router'
import {
FileCode2, GraduationCap, ShieldCheck, Users, BookOpen, BarChart3,
Plus, Trash2, Edit2, Check, X, Search, ChevronDown, Award,
TrendingUp, Activity, Clock, UserCheck, UserX, Download, Filter,
AlertCircle, CheckCircle2, Archive, Eye, MoreHorizontal, Sparkles,
} from 'lucide-react'
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
LineChart, Line, PieChart, Pie, Cell, Legend,
} from 'recharts'
import { listResource, syncCollection } from '../api/client'
// ── Types ─────────────────────────────────────────────────────
type UserRole = 'admin' | 'instructor' | 'learner'
type UserStatus = 'active' | 'inactive' | 'pending'
type AdminUser = {
id: string
name: string
email: string
role: UserRole
status: UserStatus
avatar: string
createdAt: number
lastActive: number
}
type EnrollmentStatus = 'not_started' | 'in_progress' | 'completed' | 'failed'
type Enrollment = {
id: string
userId: string
courseId: string
courseTitle: string
courseType: 'cms' | 'scorm'
status: EnrollmentStatus
score: number | null
progress: number
startedAt: number | null
completedAt: number | null
timeSpentMin: number
}
type CourseEntry = {
id: string
title: string
type: 'cms' | 'scorm'
status: 'published' | 'draft' | 'archived'
createdAt: number
updatedAt: number
enrollments: number
completions: number
avgScore: number | null
}
// ── Seed data ─────────────────────────────────────────────────
function seedUsers(): AdminUser[] {
return [
{ id: 'u1', name: 'Alex Morgan', email: 'alex@example.com', role: 'admin', status: 'active', avatar: 'AM', createdAt: Date.now() - 90*864e5, lastActive: Date.now() - 3600e3 },
{ id: 'u2', name: 'Jamie Lee', email: 'jamie@example.com', role: 'instructor', status: 'active', avatar: 'JL', createdAt: Date.now() - 60*864e5, lastActive: Date.now() - 2*864e5 },
{ id: 'u3', name: 'Taylor Smith', email: 'taylor@example.com', role: 'learner', status: 'active', avatar: 'TS', createdAt: Date.now() - 45*864e5, lastActive: Date.now() - 864e5 },
{ id: 'u4', name: 'Jordan Park', email: 'jordan@example.com', role: 'learner', status: 'active', avatar: 'JP', createdAt: Date.now() - 30*864e5, lastActive: Date.now() - 4*864e5 },
{ id: 'u5', name: 'Casey Kim', email: 'casey@example.com', role: 'learner', status: 'inactive', avatar: 'CK', createdAt: Date.now() - 20*864e5, lastActive: Date.now() - 15*864e5 },
{ id: 'u6', name: 'Riley Chen', email: 'riley@example.com', role: 'learner', status: 'pending', avatar: 'RC', createdAt: Date.now() - 2*864e5, lastActive: Date.now() - 2*864e5 },
{ id: 'u7', name: 'Sam Rivera', email: 'sam@example.com', role: 'instructor', status: 'active', avatar: 'SR', createdAt: Date.now() - 50*864e5, lastActive: Date.now() - 5*864e5 },
]
}
function seedEnrollments(users: AdminUser[], courses: CourseEntry[]): Enrollment[] {
const enrs: Enrollment[] = []
let idx = 0
for (const u of users.filter(u => u.role !== 'admin')) {
for (const c of courses) {
const chance = Math.random()
if (chance > 0.35) {
const status: EnrollmentStatus = chance > 0.85 ? 'completed' : chance > 0.6 ? 'in_progress' : 'not_started'
enrs.push({
id: `enr-${idx++}`,
userId: u.id,
courseId: c.id,
courseTitle: c.title,
courseType: c.type,
status,
score: status === 'completed' ? Math.round(60 + Math.random() * 40) : null,
progress: status === 'completed' ? 100 : status === 'in_progress' ? Math.round(20 + Math.random() * 60) : 0,
startedAt: status !== 'not_started' ? Date.now() - Math.round(Math.random() * 20 * 864e5) : null,
completedAt: status === 'completed' ? Date.now() - Math.round(Math.random() * 10 * 864e5) : null,
timeSpentMin: status === 'completed' ? Math.round(10 + Math.random() * 50) : status === 'in_progress' ? Math.round(5 + Math.random() * 25) : 0,
})
}
}
}
return enrs
}
// ── Load / save ───────────────────────────────────────────────
async function loadUsers(): Promise<AdminUser[]> {
const users = await listResource<AdminUser>('users')
if (users.length > 0) return users
const seeded = seedUsers()
await syncCollection('users', seeded)
return seeded
}
async function saveUsers(users: AdminUser[]) {
await syncCollection('users', users)
}
async function loadCmsPages(): Promise<CourseEntry[]> {
const pages = await listResource<{ id: string; name: string; createdAt?: number; updatedAt?: number }>('pages')
return pages.map(p => ({
id: p.id, title: p.name, type: 'cms' as const,
status: 'published' as const,
createdAt: p.createdAt ?? Date.now(),
updatedAt: p.updatedAt ?? Date.now(),
enrollments: Math.round(2 + Math.random() * 8),
completions: Math.round(Math.random() * 5),
avgScore: null,
}))
}
async function loadScormCourses(): Promise<CourseEntry[]> {
const courses = await listResource<{ id: string; title: string; createdAt?: number; updatedAt?: number }>('scorm')
return courses.map(course => ({
id: course.id, title: course.title, type: 'scorm' as const,
status: 'published' as const,
createdAt: course.createdAt ?? Date.now(),
updatedAt: course.updatedAt ?? Date.now(),
enrollments: Math.round(3 + Math.random() * 10),
completions: Math.round(Math.random() * 6),
avgScore: Math.round(70 + Math.random() * 20),
}))
}
async function loadEnrollments(users: AdminUser[], courses: CourseEntry[]): Promise<Enrollment[]> {
const enrollments = await listResource<Enrollment>('enrollments')
if (enrollments.length > 0) return enrollments
const seeded = seedEnrollments(users, courses)
await syncCollection('enrollments', seeded)
return seeded
}
async function saveEnrollments(e: Enrollment[]) {
await syncCollection('enrollments', e)
}
// ── Helpers ───────────────────────────────────────────────────
const ROLE_COLORS: Record<UserRole, string> = {
admin: 'bg-purple-100 text-purple-700',
instructor: 'bg-blue-100 text-blue-700',
learner: 'bg-zinc-100 text-zinc-600',
}
const STATUS_COLORS: Record<UserStatus, string> = {
active: 'bg-green-100 text-green-700',
inactive: 'bg-zinc-100 text-zinc-500',
pending: 'bg-amber-100 text-amber-700',
}
const ENR_COLORS: Record<EnrollmentStatus, string> = {
completed: 'bg-green-100 text-green-700',
in_progress: 'bg-blue-100 text-blue-700',
not_started: 'bg-zinc-100 text-zinc-500',
failed: 'bg-red-100 text-red-600',
}
function timeAgo(ts: number) {
const d = Date.now() - ts
if (d < 60e3) return 'just now'
if (d < 3600e3) return `${Math.round(d / 60e3)}m ago`
if (d < 864e5) return `${Math.round(d / 3600e3)}h ago`
return `${Math.round(d / 864e5)}d ago`
}
function uid() { return Math.random().toString(36).slice(2, 9) }
const CHART_COLORS = ['#6366f1', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4']
// ── Add User Modal ─────────────────────────────────────────────
function AddUserModal({ onSave, onClose }: { onSave: (u: AdminUser) => void; onClose: () => void }) {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [role, setRole] = useState<UserRole>('learner')
function submit() {
if (!name.trim() || !email.trim()) return
const initials = name.trim().split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2)
onSave({
id: `u-${uid()}`, name: name.trim(), email: email.trim(), role,
status: 'pending', avatar: initials,
createdAt: Date.now(), lastActive: Date.now(),
})
onClose()
}
return (
<div data-testid="admin-add-user-modal" className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm" onClick={onClose}>
<div className="bg-white rounded-2xl shadow-2xl w-[420px] max-w-[95vw] overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="px-6 py-4 border-b border-zinc-100 flex items-center justify-between">
<h2 className="text-sm font-semibold text-zinc-900">Add User</h2>
<button onClick={onClose} className="text-zinc-400 hover:text-zinc-600"><X size={16} /></button>
</div>
<div className="p-6 space-y-4">
<div>
<label className="text-xs font-medium text-zinc-600 block mb-1.5">Full Name</label>
<input data-testid="admin-add-user-name" value={name} onChange={e => setName(e.target.value)} placeholder="Jane Smith"
className="w-full text-sm px-3 py-2 border border-zinc-200 rounded-lg outline-none focus:border-indigo-400 bg-zinc-50" />
</div>
<div>
<label className="text-xs font-medium text-zinc-600 block mb-1.5">Email</label>
<input data-testid="admin-add-user-email" value={email} onChange={e => setEmail(e.target.value)} placeholder="jane@example.com" type="email"
className="w-full text-sm px-3 py-2 border border-zinc-200 rounded-lg outline-none focus:border-indigo-400 bg-zinc-50" />
</div>
<div>
<label className="text-xs font-medium text-zinc-600 block mb-1.5">Role</label>
<select data-testid="admin-add-user-role" value={role} onChange={e => setRole(e.target.value as UserRole)}
className="w-full text-sm px-3 py-2 border border-zinc-200 rounded-lg outline-none focus:border-indigo-400 bg-zinc-50">
<option value="learner">Learner</option>
<option value="instructor">Instructor</option>
<option value="admin">Admin</option>
</select>
</div>
</div>
<div className="px-6 pb-5 flex justify-end gap-2">
<button onClick={onClose} className="px-4 py-2 text-xs font-medium text-zinc-600 hover:bg-zinc-50 border border-zinc-200 rounded-lg transition-colors">Cancel</button>
<button data-testid="admin-add-user-submit" onClick={submit} disabled={!name.trim() || !email.trim()}
className="px-4 py-2 text-xs font-medium bg-indigo-600 hover:bg-indigo-700 disabled:opacity-40 text-white rounded-lg transition-colors">
Add User
</button>
</div>
</div>
</div>
)
}
// ── Users Tab ──────────────────────────────────────────────────
function UsersTab({ users, setUsers, enrollments }: { users: AdminUser[]; setUsers: (u: AdminUser[]) => void; enrollments: Enrollment[] }) {
const [search, setSearch] = useState('')
const [roleFilter, setRoleFilter] = useState<UserRole | 'all'>('all')
const [showAdd, setShowAdd] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [editRole, setEditRole] = useState<UserRole>('learner')
const filtered = useMemo(() => users.filter(u => {
const matchSearch = u.name.toLowerCase().includes(search.toLowerCase()) || u.email.toLowerCase().includes(search.toLowerCase())
const matchRole = roleFilter === 'all' || u.role === roleFilter
return matchSearch && matchRole
}), [users, search, roleFilter])
function updateUser(id: string, patch: Partial<AdminUser>) {
const next = users.map(u => u.id === id ? { ...u, ...patch } : u)
setUsers(next); void saveUsers(next)
}
function deleteUser(id: string) {
const next = users.filter(u => u.id !== id)
setUsers(next); void saveUsers(next)
}
function addUser(u: AdminUser) {
const next = [...users, u]
setUsers(next); void saveUsers(next)
}
function startEdit(u: AdminUser) { setEditingId(u.id); setEditRole(u.role) }
function saveEdit(id: string) { updateUser(id, { role: editRole }); setEditingId(null) }
const stats = useMemo(() => ({
total: users.length,
active: users.filter(u => u.status === 'active').length,
pending: users.filter(u => u.status === 'pending').length,
byRole: {
admin: users.filter(u => u.role === 'admin').length,
instructor: users.filter(u => u.role === 'instructor').length,
learner: users.filter(u => u.role === 'learner').length,
},
}), [users])
return (
<div className="flex-1 overflow-auto p-6">
{/* Stats */}
<div className="grid grid-cols-4 gap-4 mb-6">
{[
{ label: 'Total Users', value: stats.total, icon: <Users size={16} />, color: 'text-indigo-600 bg-indigo-50' },
{ label: 'Active', value: stats.active, icon: <UserCheck size={16} />, color: 'text-green-600 bg-green-50' },
{ label: 'Pending', value: stats.pending, icon: <AlertCircle size={16} />, color: 'text-amber-600 bg-amber-50' },
{ label: 'Instructors', value: stats.byRole.instructor, icon: <Award size={16} />, color: 'text-blue-600 bg-blue-50' },
].map(s => (
<div key={s.label} className="bg-white rounded-xl border border-zinc-200 p-4 flex items-center gap-3">
<div className={`w-9 h-9 rounded-lg flex items-center justify-center ${s.color}`}>{s.icon}</div>
<div>
<div className="text-xl font-bold text-zinc-900">{s.value}</div>
<div className="text-xs text-zinc-500">{s.label}</div>
</div>
</div>
))}
</div>
{/* Toolbar */}
<div className="flex items-center gap-3 mb-4">
<div className="relative flex-1 max-w-xs">
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" />
<input data-testid="admin-users-search" value={search} onChange={e => setSearch(e.target.value)} placeholder="Search users…"
className="w-full text-xs pl-8 pr-3 py-2 border border-zinc-200 rounded-lg outline-none focus:border-indigo-400 bg-white" />
</div>
<select data-testid="admin-users-role-filter" value={roleFilter} onChange={e => setRoleFilter(e.target.value as UserRole | 'all')}
className="text-xs px-3 py-2 border border-zinc-200 rounded-lg bg-white outline-none text-zinc-600">
<option value="all">All Roles</option>
<option value="admin">Admin</option>
<option value="instructor">Instructor</option>
<option value="learner">Learner</option>
</select>
<button data-testid="admin-users-open-add-user" onClick={() => setShowAdd(true)}
className="flex items-center gap-1.5 px-3 py-2 text-xs font-medium bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors ml-auto">
<Plus size={13} />Add User
</button>
</div>
{/* Table */}
<div className="bg-white rounded-xl border border-zinc-200 overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-zinc-100 bg-zinc-50">
<th className="text-left px-4 py-3 font-semibold text-zinc-500">User</th>
<th className="text-left px-4 py-3 font-semibold text-zinc-500">Role</th>
<th className="text-left px-4 py-3 font-semibold text-zinc-500">Status</th>
<th className="text-left px-4 py-3 font-semibold text-zinc-500">Enrollments</th>
<th className="text-left px-4 py-3 font-semibold text-zinc-500">Last Active</th>
<th className="text-left px-4 py-3 font-semibold text-zinc-500">Actions</th>
</tr>
</thead>
<tbody>
{filtered.map(u => {
const userEnr = enrollments.filter(e => e.userId === u.id)
const completed = userEnr.filter(e => e.status === 'completed').length
return (
<tr key={u.id} className="border-b border-zinc-50 hover:bg-zinc-50/50 transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-full bg-indigo-100 text-indigo-700 flex items-center justify-center text-[10px] font-bold flex-shrink-0">{u.avatar}</div>
<div>
<div className="font-medium text-zinc-800">{u.name}</div>
<div className="text-zinc-400">{u.email}</div>
</div>
</div>
</td>
<td className="px-4 py-3">
{editingId === u.id ? (
<div className="flex items-center gap-1">
<select value={editRole} onChange={e => setEditRole(e.target.value as UserRole)}
className="text-xs px-2 py-1 border border-indigo-300 rounded-md outline-none bg-indigo-50">
<option value="learner">Learner</option>
<option value="instructor">Instructor</option>
<option value="admin">Admin</option>
</select>
<button onClick={() => saveEdit(u.id)} className="text-green-500 hover:text-green-700"><Check size={13} /></button>
<button onClick={() => setEditingId(null)} className="text-zinc-400 hover:text-zinc-600"><X size={13} /></button>
</div>
) : (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold ${ROLE_COLORS[u.role]}`}>{u.role}</span>
)}
</td>
<td className="px-4 py-3">
<button data-testid={`admin-user-status-toggle-${u.id}`} onClick={() => updateUser(u.id, { status: u.status === 'active' ? 'inactive' : 'active' })}
className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold ${STATUS_COLORS[u.status]} hover:opacity-80 transition-opacity`}>
{u.status}
</button>
</td>
<td className="px-4 py-3">
<span className="text-zinc-700 font-medium">{completed}</span>
<span className="text-zinc-400"> / {userEnr.length}</span>
</td>
<td className="px-4 py-3 text-zinc-400">{timeAgo(u.lastActive)}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<button data-testid={`admin-user-edit-role-${u.id}`} onClick={() => startEdit(u)} className="w-6 h-6 flex items-center justify-center text-zinc-300 hover:text-indigo-500 transition-colors" title="Edit role"><Edit2 size={12} /></button>
<button onClick={() => updateUser(u.id, { status: u.status === 'active' ? 'inactive' : 'active' })}
className="w-6 h-6 flex items-center justify-center text-zinc-300 hover:text-amber-500 transition-colors" title="Toggle status">
{u.status === 'active' ? <UserX size={12} /> : <UserCheck size={12} />}
</button>
<button data-testid={`admin-user-delete-${u.id}`} onClick={() => deleteUser(u.id)} className="w-6 h-6 flex items-center justify-center text-zinc-300 hover:text-red-400 transition-colors" title="Delete"><Trash2 size={12} /></button>
</div>
</td>
</tr>
)
})}
{filtered.length === 0 && (
<tr><td colSpan={6} className="px-4 py-10 text-center text-zinc-400">No users found</td></tr>
)}
</tbody>
</table>
</div>
{showAdd && <AddUserModal onSave={addUser} onClose={() => setShowAdd(false)} />}
</div>
)
}
// ── Courses Tab ────────────────────────────────────────────────
function CoursesTab({ courses, setCourses, users, enrollments, setEnrollments }:
{ courses: CourseEntry[]; setCourses: (c: CourseEntry[]) => void; users: AdminUser[]; enrollments: Enrollment[]; setEnrollments: (e: Enrollment[]) => void }) {
const [search, setSearch] = useState('')
const [typeFilter, setTypeFilter] = useState<'all' | 'cms' | 'scorm'>('all')
const [showAssign, setShowAssign] = useState<CourseEntry | null>(null)
const [selectedUsers, setSelectedUsers] = useState<Set<string>>(new Set())
const filtered = useMemo(() => courses.filter(c => {
const matchSearch = c.title.toLowerCase().includes(search.toLowerCase())
const matchType = typeFilter === 'all' || c.type === typeFilter
return matchSearch && matchType
}), [courses, search, typeFilter])
function toggleStatus(id: string, status: CourseEntry['status']) {
const next = courses.map(c => c.id === id ? { ...c, status } : c)
setCourses(next)
}
function openAssign(course: CourseEntry) {
const enrolled = new Set(enrollments.filter(e => e.courseId === course.id).map(e => e.userId))
setSelectedUsers(enrolled)
setShowAssign(course)
}
function saveAssign() {
if (!showAssign) return
const existing = enrollments.filter(e => e.courseId !== showAssign.id)
const newEnr: Enrollment[] = [...selectedUsers].map(uid => ({
id: `enr-${Math.random().toString(36).slice(2,8)}`,
userId: uid, courseId: showAssign.id, courseTitle: showAssign.title,
courseType: showAssign.type, status: 'not_started',
score: null, progress: 0, startedAt: null, completedAt: null, timeSpentMin: 0,
}))
const next = [...existing, ...newEnr]
setEnrollments(next); void saveEnrollments(next)
setShowAssign(null)
}
return (
<div className="flex-1 overflow-auto p-6">
{/* Stats row */}
<div className="grid grid-cols-4 gap-4 mb-6">
{[
{ label: 'Total Courses', value: courses.length, icon: <BookOpen size={16} />, color: 'text-indigo-600 bg-indigo-50' },
{ label: 'Published', value: courses.filter(c => c.status === 'published').length, icon: <CheckCircle2 size={16} />, color: 'text-green-600 bg-green-50' },
{ label: 'SCORM Courses', value: courses.filter(c => c.type === 'scorm').length, icon: <GraduationCap size={16} />, color: 'text-purple-600 bg-purple-50' },
{ label: 'CMS Pages', value: courses.filter(c => c.type === 'cms').length, icon: <FileCode2 size={16} />, color: 'text-blue-600 bg-blue-50' },
].map(s => (
<div key={s.label} className="bg-white rounded-xl border border-zinc-200 p-4 flex items-center gap-3">
<div className={`w-9 h-9 rounded-lg flex items-center justify-center ${s.color}`}>{s.icon}</div>
<div>
<div className="text-xl font-bold text-zinc-900">{s.value}</div>
<div className="text-xs text-zinc-500">{s.label}</div>
</div>
</div>
))}
</div>
{/* Toolbar */}
<div className="flex items-center gap-3 mb-4">
<div className="relative flex-1 max-w-xs">
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" />
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search courses…"
className="w-full text-xs pl-8 pr-3 py-2 border border-zinc-200 rounded-lg outline-none focus:border-indigo-400 bg-white" />
</div>
<select value={typeFilter} onChange={e => setTypeFilter(e.target.value as 'all' | 'cms' | 'scorm')}
className="text-xs px-3 py-2 border border-zinc-200 rounded-lg bg-white outline-none text-zinc-600">
<option value="all">All Types</option>
<option value="scorm">SCORM</option>
<option value="cms">CMS Pages</option>
</select>
</div>
{/* Table */}
<div className="bg-white rounded-xl border border-zinc-200 overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-zinc-100 bg-zinc-50">
<th className="text-left px-4 py-3 font-semibold text-zinc-500">Course</th>
<th className="text-left px-4 py-3 font-semibold text-zinc-500">Type</th>
<th className="text-left px-4 py-3 font-semibold text-zinc-500">Status</th>
<th className="text-left px-4 py-3 font-semibold text-zinc-500">Enrolled</th>
<th className="text-left px-4 py-3 font-semibold text-zinc-500">Completions</th>
<th className="text-left px-4 py-3 font-semibold text-zinc-500">Avg Score</th>
<th className="text-left px-4 py-3 font-semibold text-zinc-500">Actions</th>
</tr>
</thead>
<tbody>
{filtered.map(c => {
const courseEnr = enrollments.filter(e => e.courseId === c.id)
const completions = courseEnr.filter(e => e.status === 'completed').length
const scores = courseEnr.filter(e => e.score !== null).map(e => e.score!)
const avgScore = scores.length ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length) : null
return (
<tr key={c.id} className="border-b border-zinc-50 hover:bg-zinc-50/50 transition-colors">
<td className="px-4 py-3">
<div className="font-medium text-zinc-800 truncate max-w-[200px]">{c.title}</div>
<div className="text-zinc-400">{timeAgo(c.updatedAt)}</div>
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold ${c.type === 'scorm' ? 'bg-purple-100 text-purple-700' : 'bg-blue-100 text-blue-700'}`}>
{c.type === 'scorm' ? <GraduationCap size={9} /> : <FileCode2 size={9} />}
{c.type.toUpperCase()}
</span>
</td>
<td className="px-4 py-3">
<select value={c.status} onChange={e => toggleStatus(c.id, e.target.value as CourseEntry['status'])}
className={`text-[10px] font-semibold px-2 py-0.5 rounded-full border-none outline-none cursor-pointer ${c.status === 'published' ? 'bg-green-100 text-green-700' : c.status === 'archived' ? 'bg-zinc-100 text-zinc-500' : 'bg-amber-100 text-amber-700'}`}>
<option value="published">Published</option>
<option value="draft">Draft</option>
<option value="archived">Archived</option>
</select>
</td>
<td className="px-4 py-3 font-medium text-zinc-700">{courseEnr.length}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<span className="font-medium text-zinc-700">{completions}</span>
{courseEnr.length > 0 && (
<div className="flex-1 max-w-[60px] h-1.5 bg-zinc-100 rounded-full overflow-hidden">
<div className="h-full bg-green-400 rounded-full" style={{ width: `${Math.round(completions / courseEnr.length * 100)}%` }} />
</div>
)}
</div>
</td>
<td className="px-4 py-3">
{avgScore !== null ? <span className="font-medium text-zinc-700">{avgScore}%</span> : <span className="text-zinc-300">—</span>}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<button data-testid={`admin-course-assign-${c.id}`} onClick={() => openAssign(c)}
className="flex items-center gap-1 px-2 py-1 text-[10px] font-medium text-indigo-600 hover:bg-indigo-50 rounded-md transition-colors">
<Users size={10} />Assign
</button>
</div>
</td>
</tr>
)
})}
{filtered.length === 0 && (
<tr><td colSpan={7} className="px-4 py-10 text-center text-zinc-400">No courses found. Create pages in the Builder or SCORM tabs.</td></tr>
)}
</tbody>
</table>
</div>
{/* Assign Modal */}
{showAssign && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm" onClick={() => setShowAssign(null)}>
<div className="bg-white rounded-2xl shadow-2xl w-[480px] max-w-[95vw] max-h-[80vh] flex flex-col overflow-hidden" onClick={e => e.stopPropagation()}>
<div className="px-6 py-4 border-b border-zinc-100 flex items-center justify-between flex-shrink-0">
<div>
<h2 className="text-sm font-semibold text-zinc-900">Assign Users</h2>
<p className="text-xs text-zinc-400 mt-0.5">{showAssign.title}</p>
</div>
<button onClick={() => setShowAssign(null)} className="text-zinc-400 hover:text-zinc-600"><X size={16} /></button>
</div>
<div className="flex-1 overflow-y-auto p-4">
{users.filter(u => u.role !== 'admin').map(u => (
<label key={u.id} className="flex items-center gap-3 px-3 py-2.5 hover:bg-zinc-50 rounded-lg cursor-pointer">
<input type="checkbox" checked={selectedUsers.has(u.id)}
onChange={e => {
const next = new Set(selectedUsers)
e.target.checked ? next.add(u.id) : next.delete(u.id)
setSelectedUsers(next)
}}
className="accent-indigo-500 w-4 h-4" />
<div className="w-7 h-7 rounded-full bg-indigo-100 text-indigo-700 flex items-center justify-center text-[10px] font-bold flex-shrink-0">{u.avatar}</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-zinc-800">{u.name}</div>
<div className="text-[10px] text-zinc-400">{u.email}</div>
</div>
<span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded-full ${ROLE_COLORS[u.role]}`}>{u.role}</span>
</label>
))}
</div>
<div className="px-6 pb-4 pt-3 border-t border-zinc-100 flex justify-between items-center flex-shrink-0">
<span className="text-xs text-zinc-400">{selectedUsers.size} selected</span>
<div className="flex gap-2">
<button onClick={() => setShowAssign(null)} className="px-4 py-2 text-xs font-medium text-zinc-600 border border-zinc-200 rounded-lg hover:bg-zinc-50">Cancel</button>
<button data-testid="admin-course-assign-save" onClick={saveAssign} className="px-4 py-2 text-xs font-medium bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg">Save</button>
</div>
</div>
</div>
</div>
)}
</div>
)
}
// ── Analytics Tab ──────────────────────────────────────────────
function AnalyticsTab({ users, courses, enrollments }: { users: AdminUser[]; courses: CourseEntry[]; enrollments: Enrollment[] }) {
const stats = useMemo(() => {
const completed = enrollments.filter(e => e.status === 'completed')
const inProgress = enrollments.filter(e => e.status === 'in_progress')
const scores = completed.filter(e => e.score !== null).map(e => e.score!)
const avgScore = scores.length ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length) : 0
const totalTime = enrollments.reduce((s, e) => s + e.timeSpentMin, 0)
return { completed: completed.length, inProgress: inProgress.length, avgScore, totalTime }
}, [enrollments])
// Completions by course
const completionByCourse = useMemo(() => courses.map(c => {
const enr = enrollments.filter(e => e.courseId === c.id)
const done = enr.filter(e => e.status === 'completed').length
return {
name: c.title.length > 18 ? c.title.slice(0, 18) + '…' : c.title,
enrolled: enr.length,
completed: done,
rate: enr.length ? Math.round(done / enr.length * 100) : 0,
}
}).filter(c => c.enrolled > 0), [courses, enrollments])
// Score distribution
const scoreDist = useMemo(() => {
const buckets = [
{ range: '0–59', min: 0, max: 59, count: 0 },
{ range: '60–69', min: 60, max: 69, count: 0 },
{ range: '70–79', min: 70, max: 79, count: 0 },
{ range: '80–89', min: 80, max: 89, count: 0 },
{ range: '90–100', min: 90, max: 100, count: 0 },
]
for (const e of enrollments.filter(e => e.score !== null)) {
const b = buckets.find(b => e.score! >= b.min && e.score! <= b.max)
if (b) b.count++
}
return buckets
}, [enrollments])
// Status breakdown pie
const statusPie = useMemo(() => [
{ name: 'Completed', value: enrollments.filter(e => e.status === 'completed').length },
{ name: 'In Progress', value: enrollments.filter(e => e.status === 'in_progress').length },
{ name: 'Not Started', value: enrollments.filter(e => e.status === 'not_started').length },
].filter(d => d.value > 0), [enrollments])
// Top learners
const topLearners = useMemo(() => {
return users.filter(u => u.role === 'learner').map(u => {
const userEnr = enrollments.filter(e => e.userId === u.id)
const completed = userEnr.filter(e => e.status === 'completed')
const scores = completed.filter(e => e.score !== null).map(e => e.score!)
const avg = scores.length ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length) : 0
return { ...u, completions: completed.length, avgScore: avg, timeSpent: userEnr.reduce((s, e) => s + e.timeSpentMin, 0) }
}).sort((a, b) => b.completions - a.completions || b.avgScore - a.avgScore).slice(0, 5)
}, [users, enrollments])
// Recent activity
const recentActivity = useMemo(() => {
return enrollments
.filter(e => e.completedAt || e.startedAt)
.map(e => {
const user = users.find(u => u.id === e.userId)
return { ...e, userName: user?.name ?? 'Unknown', userAvatar: user?.avatar ?? '?' }
})
.sort((a, b) => (b.completedAt ?? b.startedAt ?? 0) - (a.completedAt ?? a.startedAt ?? 0))
.slice(0, 8)
}, [enrollments, users])
return (
<div className="flex-1 overflow-auto p-6">
{/* KPI cards */}
<div className="grid grid-cols-4 gap-4 mb-6">
{[
{ label: 'Completions', value: stats.completed, icon: <CheckCircle2 size={16} />, color: 'text-green-600 bg-green-50', sub: 'total' },
{ label: 'In Progress', value: stats.inProgress, icon: <Activity size={16} />, color: 'text-blue-600 bg-blue-50', sub: 'active' },
{ label: 'Avg Score', value: `${stats.avgScore}%`, icon: <Award size={16} />, color: 'text-indigo-600 bg-indigo-50', sub: 'across completions' },
{ label: 'Time Spent', value: `${Math.round(stats.totalTime / 60)}h`, icon: <Clock size={16} />, color: 'text-amber-600 bg-amber-50', sub: 'total learning time' },
].map(s => (
<div key={s.label} className="bg-white rounded-xl border border-zinc-200 p-4 flex items-center gap-3">
<div className={`w-9 h-9 rounded-lg flex items-center justify-center ${s.color}`}>{s.icon}</div>
<div>
<div className="text-xl font-bold text-zinc-900">{s.value}</div>
<div className="text-xs text-zinc-500">{s.label}</div>
</div>
</div>
))}
</div>
<div className="grid grid-cols-3 gap-4 mb-4">
{/* Completion by course */}
<div className="col-span-2 bg-white rounded-xl border border-zinc-200 p-5">
<h3 className="text-xs font-semibold text-zinc-700 mb-4">Completions by Course</h3>
{completionByCourse.length > 0 ? (
<ResponsiveContainer width="100%" height={200}>
<BarChart data={completionByCourse} margin={{ top: 0, right: 0, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<XAxis dataKey="name" tick={{ fontSize: 10, fill: '#94a3b8' }} />
<YAxis tick={{ fontSize: 10, fill: '#94a3b8' }} />
<Tooltip contentStyle={{ fontSize: 11, borderRadius: 8, border: '1px solid #e2e8f0' }} />
<Bar dataKey="enrolled" fill="#e0e7ff" name="Enrolled" radius={[4, 4, 0, 0]} />
<Bar dataKey="completed" fill="#6366f1" name="Completed" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-[200px] flex items-center justify-center text-xs text-zinc-300">No enrollment data yet</div>
)}
</div>
{/* Status pie */}
<div className="bg-white rounded-xl border border-zinc-200 p-5">
<h3 className="text-xs font-semibold text-zinc-700 mb-4">Enrollment Status</h3>
{statusPie.length > 0 ? (
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie data={statusPie} cx="50%" cy="50%" innerRadius={50} outerRadius={75} paddingAngle={3} dataKey="value">
{statusPie.map((_, i) => <Cell key={i} fill={CHART_COLORS[i]} />)}
</Pie>
<Tooltip contentStyle={{ fontSize: 11, borderRadius: 8, border: '1px solid #e2e8f0' }} />
<Legend iconSize={8} wrapperStyle={{ fontSize: 10 }} />
</PieChart>
</ResponsiveContainer>
) : (
<div className="h-[200px] flex items-center justify-center text-xs text-zinc-300">No data yet</div>
)}
</div>
</div>
<div className="grid grid-cols-3 gap-4">
{/* Score distribution */}
<div className="bg-white rounded-xl border border-zinc-200 p-5">
<h3 className="text-xs font-semibold text-zinc-700 mb-4">Score Distribution</h3>
<ResponsiveContainer width="100%" height={160}>
<BarChart data={scoreDist} margin={{ top: 0, right: 0, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<XAxis dataKey="range" tick={{ fontSize: 9, fill: '#94a3b8' }} />
<YAxis tick={{ fontSize: 9, fill: '#94a3b8' }} />
<Tooltip contentStyle={{ fontSize: 11, borderRadius: 8, border: '1px solid #e2e8f0' }} />
<Bar dataKey="count" fill="#10b981" name="Learners" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
{/* Top learners */}
<div className="bg-white rounded-xl border border-zinc-200 p-5">
<h3 className="text-xs font-semibold text-zinc-700 mb-4">Top Learners</h3>
<div className="space-y-3">
{topLearners.map((u, i) => (
<div key={u.id} className="flex items-center gap-2.5">
<span className="text-[10px] font-bold text-zinc-300 w-4">{i + 1}</span>
<div className="w-6 h-6 rounded-full bg-indigo-100 text-indigo-700 flex items-center justify-center text-[9px] font-bold flex-shrink-0">{u.avatar}</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-zinc-700 truncate">{u.name}</div>
<div className="text-[10px] text-zinc-400">{u.completions} completions · {u.avgScore}% avg</div>
</div>
</div>
))}
{topLearners.length === 0 && <p className="text-xs text-zinc-300 text-center py-4">No data yet</p>}
</div>
</div>
{/* Recent activity */}
<div className="bg-white rounded-xl border border-zinc-200 p-5">
<h3 className="text-xs font-semibold text-zinc-700 mb-4">Recent Activity</h3>
<div className="space-y-3">
{recentActivity.map(e => (
<div key={e.id} className="flex items-start gap-2">
<div className="w-6 h-6 rounded-full bg-indigo-100 text-indigo-700 flex items-center justify-center text-[9px] font-bold flex-shrink-0 mt-0.5">{e.userAvatar}</div>
<div className="flex-1 min-w-0">
<div className="text-[10px] text-zinc-600 leading-tight">
<span className="font-medium text-zinc-800">{e.userName}</span>
{' '}{e.status === 'completed' ? 'completed' : 'started'}{' '}
<span className="text-indigo-600 truncate">{e.courseTitle}</span>
</div>
<div className="text-[10px] text-zinc-400 mt-0.5">{timeAgo(e.completedAt ?? e.startedAt ?? Date.now())}</div>
</div>
<span className={`text-[9px] font-semibold px-1.5 py-0.5 rounded-full flex-shrink-0 ${ENR_COLORS[e.status]}`}>{e.status.replace('_', ' ')}</span>
</div>
))}
{recentActivity.length === 0 && <p className="text-xs text-zinc-300 text-center py-4">No activity yet</p>}
</div>
</div>
</div>
</div>
)
}
// ── Main AdminPage ─────────────────────────────────────────────
export function AdminPage() {
const [tab, setTab] = useState<'users' | 'courses' | 'analytics'>('users')
const [users, setUsers] = useState<AdminUser[]>([])
const [courses, setCourses] = useState<CourseEntry[]>([])
const [enrollments, setEnrollments] = useState<Enrollment[]>([])
useEffect(() => {
let mounted = true
const bootstrap = async () => {
try {
const loadedUsers = await loadUsers()
const [cmsPages, scormCourses] = await Promise.all([loadCmsPages(), loadScormCourses()])
const loadedCourses = [...cmsPages, ...scormCourses]
const loadedEnrollments = await loadEnrollments(loadedUsers, loadedCourses)
if (!mounted) return
setUsers(loadedUsers)
setCourses(loadedCourses)
setEnrollments(loadedEnrollments)
} catch (error) {
console.error('failed to load admin data', error)
}
}
void bootstrap()
return () => { mounted = false }
}, [])
const TABS = [
{ id: 'users' as const, label: 'Users', icon: <Users size={13} /> },
{ id: 'courses' as const, label: 'Courses', icon: <BookOpen size={13} /> },
{ id: 'analytics' as const, label: 'Analytics', icon: <BarChart3 size={13} /> },
]
return (
<div data-testid="admin-page" className="flex flex-col h-screen w-screen overflow-hidden bg-zinc-100">
{/* Header */}
<header className="flex items-center justify-between px-5 h-12 bg-white border-b border-zinc-200 flex-shrink-0 z-30">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-md bg-indigo-600 flex items-center justify-center">
<ShieldCheck size={13} color="#fff" />
</div>
<span className="text-sm font-semibold text-zinc-900">Admin</span>
</div>
<div className="w-px h-4 bg-zinc-200" />
{/* Nav */}
<div className="flex items-center gap-0.5 bg-zinc-100 rounded-md p-0.5">
<Link data-testid="admin-nav-builder" to="/admin/builder" className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-zinc-500 hover:text-zinc-700 rounded transition-colors">
<FileCode2 size={11} />Builder
</Link>
<Link data-testid="admin-nav-scorm" to="/scorm" className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-zinc-500 hover:text-zinc-700 rounded transition-colors">
<GraduationCap size={11} />SCORM
</Link>
<Link data-testid="admin-nav-keycloak-themes" to="/admin/keycloak-themes" className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-zinc-500 hover:text-zinc-700 rounded transition-colors">
<Sparkles size={11} />Themes
</Link>
<span className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium bg-white text-zinc-900 rounded shadow-sm">
<ShieldCheck size={11} />Admin
</span>
</div>
</div>
{/* Tab switcher */}
<div className="flex items-center gap-0.5 bg-zinc-100 rounded-lg p-0.5">
{TABS.map(t => (
<button data-testid={`admin-tab-${t.id}`} key={t.id} onClick={() => setTab(t.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${tab === t.id ? 'bg-white text-zinc-900 shadow-sm' : 'text-zinc-500 hover:text-zinc-700'}`}>
{t.icon}{t.label}
</button>
))}
</div>
<div className="flex items-center gap-2">
<div className="text-xs text-zinc-400">{users.filter(u => u.status === 'active').length} active users</div>
</div>
</header>
{/* Body */}
<div className="flex-1 overflow-hidden flex flex-col">
{tab === 'users' && <UsersTab users={users} setUsers={setUsers} enrollments={enrollments} />}
{tab === 'courses' && <CoursesTab courses={courses} setCourses={setCourses} users={users} enrollments={enrollments} setEnrollments={setEnrollments} />}
{tab === 'analytics' && <AnalyticsTab users={users} courses={courses} enrollments={enrollments} />}
</div>
</div>
)
}

View File

@ -0,0 +1,300 @@
import { useEffect, useMemo, useState } from 'react'
import {
loadAdminSettings,
saveAdminSettings,
type AdminEditableSettings,
type AdminSettingsData,
type IntegrationKey,
} from '../admin-settings'
type LoadState = 'loading' | 'ready' | 'error'
const INTEGRATION_ORDER: Array<{ key: IntegrationKey; title: string }> = [
{ key: 'sendgrid', title: 'SendGrid' },
{ key: 'stripe', title: 'Stripe' },
{ key: 'minio', title: 'MinIO' },
{ key: 'keycloak', title: 'Keycloak' },
{ key: 'assetEncryption', title: 'Asset encryption' },
]
function cloneSettings(settings: AdminEditableSettings): AdminEditableSettings {
return {
platformIdentity: { ...settings.platformIdentity },
seoDefaults: { ...settings.seoDefaults },
consentLinks: { ...settings.consentLinks },
}
}
function statusClassName(configured: boolean): string {
return configured
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
: 'border-amber-200 bg-amber-50 text-amber-700'
}
export function AdminSettingsPage() {
const [state, setState] = useState<LoadState>('loading')
const [error, setError] = useState('')
const [data, setData] = useState<AdminSettingsData | null>(null)
const [draft, setDraft] = useState<AdminEditableSettings | null>(null)
const [saving, setSaving] = useState(false)
const [saveMessage, setSaveMessage] = useState('')
const load = async () => {
try {
setState('loading')
setError('')
const next = await loadAdminSettings()
setData(next)
setDraft(cloneSettings(next.settings))
setSaveMessage('')
setState('ready')
} catch (err) {
setData(null)
setDraft(null)
setState('error')
setError(err instanceof Error ? err.message : 'Failed to load admin settings')
}
}
useEffect(() => {
void load()
}, [])
const saveDisabled = useMemo(() => {
return saving || !draft
}, [draft, saving])
const onSave = async () => {
if (!draft) return
try {
setSaving(true)
setSaveMessage('')
const updated = await saveAdminSettings(draft)
setDraft(cloneSettings(updated))
setData(prev => (prev ? { ...prev, settings: cloneSettings(updated) } : prev))
setSaveMessage('Settings saved.')
} catch (err) {
setSaveMessage(err instanceof Error ? err.message : 'Failed to save settings')
} finally {
setSaving(false)
}
}
if (state === 'loading') {
return (
<div className="p-6" data-testid="admin-settings-loading">
<h2 className="text-lg font-semibold text-zinc-900">Admin Settings</h2>
<p className="mt-2 text-sm text-zinc-600">Loading integration health and configuration...</p>
</div>
)
}
if (state === 'error') {
return (
<div className="p-6" data-testid="admin-settings-error">
<h2 className="text-lg font-semibold text-zinc-900">Admin Settings</h2>
<p className="mt-2 text-sm text-red-600">{error}</p>
<button
type="button"
onClick={() => { void load() }}
className="mt-3 rounded-md border border-zinc-200 px-3 py-1.5 text-xs font-medium text-zinc-700 hover:bg-zinc-50"
>
Retry
</button>
</div>
)
}
return (
<div className="space-y-6 p-6" data-testid="admin-settings-ready">
<header>
<h2 className="text-lg font-semibold text-zinc-900">Admin Settings</h2>
<p className="mt-1 text-sm text-zinc-600">
Integration health overview with setup guidance and editable, non-secret platform defaults.
</p>
</header>
<section className="grid gap-3 md:grid-cols-2 xl:grid-cols-3" data-testid="admin-settings-integrations">
{INTEGRATION_ORDER.map(item => {
const integration = data?.integrations[item.key]
if (!integration) return null
return (
<article key={item.key} className="rounded-lg border border-zinc-200 bg-white p-3">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-zinc-900">{item.title}</h3>
<span className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${statusClassName(integration.configured)}`}>
{integration.configured ? 'Configured' : 'Not configured'}
</span>
</div>
{integration.details.length > 0 ? (
<ul className="mt-2 space-y-1 text-xs text-zinc-600">
{integration.details.map(detail => (
<li key={`${item.key}-${detail}`}>{detail}</li>
))}
</ul>
) : (
<p className="mt-2 text-xs text-zinc-500">No additional diagnostics available.</p>
)}
<p className="mt-3 rounded-md border border-zinc-200 bg-zinc-50 p-2 text-xs text-zinc-600">
{integration.setupInstruction}
</p>
</article>
)
})}
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-4" data-testid="admin-settings-edit-form">
<div className="flex flex-wrap items-start justify-between gap-2">
<h3 className="text-sm font-semibold text-zinc-900">Editable platform settings</h3>
<button
type="button"
onClick={() => { void onSave() }}
disabled={saveDisabled}
className="rounded-md border border-zinc-200 px-3 py-1.5 text-xs font-medium text-zinc-700 hover:bg-zinc-50 disabled:cursor-not-allowed disabled:opacity-50"
data-testid="admin-settings-save"
>
{saving ? 'Saving...' : 'Save settings'}
</button>
</div>
<p className="mt-1 text-xs text-zinc-600">Secret credentials are intentionally omitted from this screen.</p>
{draft ? (
<div className="mt-4 grid gap-4 xl:grid-cols-3">
<fieldset className="space-y-2 rounded-md border border-zinc-200 p-3">
<legend className="px-1 text-xs font-semibold uppercase tracking-wide text-zinc-500">Platform identity</legend>
<label className="block text-xs text-zinc-600">
Site name
<input
type="text"
value={draft.platformIdentity.siteName}
onChange={event => setDraft(current => current ? {
...current,
platformIdentity: { ...current.platformIdentity, siteName: event.target.value },
} : current)}
className="mt-1 w-full rounded-md border border-zinc-300 px-3 py-1.5 text-sm text-zinc-900"
/>
</label>
<label className="block text-xs text-zinc-600">
Support email
<input
type="email"
value={draft.platformIdentity.supportEmail}
onChange={event => setDraft(current => current ? {
...current,
platformIdentity: { ...current.platformIdentity, supportEmail: event.target.value },
} : current)}
className="mt-1 w-full rounded-md border border-zinc-300 px-3 py-1.5 text-sm text-zinc-900"
/>
</label>
</fieldset>
<fieldset className="space-y-2 rounded-md border border-zinc-200 p-3">
<legend className="px-1 text-xs font-semibold uppercase tracking-wide text-zinc-500">SEO defaults</legend>
<label className="block text-xs text-zinc-600">
Default title
<input
type="text"
value={draft.seoDefaults.defaultTitle}
onChange={event => setDraft(current => current ? {
...current,
seoDefaults: { ...current.seoDefaults, defaultTitle: event.target.value },
} : current)}
className="mt-1 w-full rounded-md border border-zinc-300 px-3 py-1.5 text-sm text-zinc-900"
/>
</label>
<label className="block text-xs text-zinc-600">
Default description
<textarea
value={draft.seoDefaults.defaultDescription}
onChange={event => setDraft(current => current ? {
...current,
seoDefaults: { ...current.seoDefaults, defaultDescription: event.target.value },
} : current)}
rows={3}
className="mt-1 w-full rounded-md border border-zinc-300 px-3 py-1.5 text-sm text-zinc-900"
/>
</label>
<label className="block text-xs text-zinc-600">
Canonical base URL
<input
type="url"
value={draft.seoDefaults.canonicalBaseUrl}
onChange={event => setDraft(current => current ? {
...current,
seoDefaults: { ...current.seoDefaults, canonicalBaseUrl: event.target.value },
} : current)}
placeholder="https://example.com"
className="mt-1 w-full rounded-md border border-zinc-300 px-3 py-1.5 text-sm text-zinc-900"
/>
</label>
</fieldset>
<fieldset className="space-y-2 rounded-md border border-zinc-200 p-3">
<legend className="px-1 text-xs font-semibold uppercase tracking-wide text-zinc-500">Consent links</legend>
<label className="block text-xs text-zinc-600">
Privacy policy URL
<input
type="url"
value={draft.consentLinks.privacyPolicyUrl}
onChange={event => setDraft(current => current ? {
...current,
consentLinks: { ...current.consentLinks, privacyPolicyUrl: event.target.value },
} : current)}
placeholder="https://example.com/privacy"
className="mt-1 w-full rounded-md border border-zinc-300 px-3 py-1.5 text-sm text-zinc-900"
/>
</label>
<label className="block text-xs text-zinc-600">
Terms of service URL
<input
type="url"
value={draft.consentLinks.termsOfServiceUrl}
onChange={event => setDraft(current => current ? {
...current,
consentLinks: { ...current.consentLinks, termsOfServiceUrl: event.target.value },
} : current)}
placeholder="https://example.com/terms"
className="mt-1 w-full rounded-md border border-zinc-300 px-3 py-1.5 text-sm text-zinc-900"
/>
</label>
<label className="block text-xs text-zinc-600">
Cookie policy URL
<input
type="url"
value={draft.consentLinks.cookiePolicyUrl}
onChange={event => setDraft(current => current ? {
...current,
consentLinks: { ...current.consentLinks, cookiePolicyUrl: event.target.value },
} : current)}
placeholder="https://example.com/cookies"
className="mt-1 w-full rounded-md border border-zinc-300 px-3 py-1.5 text-sm text-zinc-900"
/>
</label>
</fieldset>
</div>
) : null}
{saveMessage ? (
<p className={`mt-3 text-xs ${saveMessage === 'Settings saved.' ? 'text-emerald-700' : 'text-red-600'}`} data-testid="admin-settings-save-message">
{saveMessage}
</p>
) : null}
</section>
<section className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-xs text-zinc-600" data-testid="admin-settings-environment">
<h3 className="text-xs font-semibold uppercase tracking-wide text-zinc-700">Runtime diagnostics</h3>
<ul className="mt-2 space-y-1">
<li>Notification provider: {data?.environment.notificationProvider || 'not set'}</li>
<li>Keycloak auth disabled: {data?.environment.keycloakAuthDisabled ? 'yes' : 'no'}</li>
<li>Keycloak theme deploy enabled: {data?.environment.keycloakThemeDeploy ? 'yes' : 'no'}</li>
<li>Database connected: {data?.environment.databaseConnected ? 'yes' : 'no'}</li>
</ul>
</section>
</div>
)
}

View File

@ -0,0 +1,220 @@
import { useEffect, useState } from 'react'
import { AlertCircle, ArrowLeft, CheckCircle2, ExternalLink, FileText, Loader2, Trash2 } from 'lucide-react'
import { Link, useNavigate, useParams } from 'react-router'
import { apiRequest } from '../api/client'
type ThemeProposal = {
background?: string
foreground?: string
primary?: string
primaryForeground?: string
muted?: string
mutedForeground?: string
border?: string
accent?: string
radius?: number
fontFamily?: string
}
type ThemeValidation = {
valid?: boolean
errors?: string[]
warnings?: string[]
}
type SitePageProposal = {
id?: string
name?: string
slug?: string
blocks?: unknown[]
}
type SiteProposal = {
structure?: {
title?: string
description?: string
navigation?: Array<{ label?: string; pageId?: string }>
}
pages?: Record<string, SitePageProposal>
}
type AuthoringRun = {
id: string
status: string
brief?: Record<string, unknown>
proposal?: { theme?: ThemeProposal; site?: SiteProposal }
proposalRevision?: number
validation?: { theme?: ThemeValidation; site?: ThemeValidation }
createdAt?: number
updatedAt?: number
}
const colorTokens: Array<keyof ThemeProposal> = [
'background',
'foreground',
'primary',
'primaryForeground',
'muted',
'mutedForeground',
'border',
'accent',
]
export function AuthoringRunReviewPage() {
const { runId = '' } = useParams()
const navigate = useNavigate()
const [run, setRun] = useState<AuthoringRun | null>(null)
const [error, setError] = useState<string | null>(null)
const [discarding, setDiscarding] = useState(false)
useEffect(() => {
let active = true
setError(null)
apiRequest<AuthoringRun>(`/api/authoring-runs/${encodeURIComponent(runId)}`)
.then(result => { if (active) setRun(result) })
.catch(reason => { if (active) setError(reason instanceof Error ? reason.message : 'Failed to load proposal') })
return () => { active = false }
}, [runId])
async function discard() {
if (!run || discarding) return
setDiscarding(true)
try {
await apiRequest(`/api/authoring-runs/${encodeURIComponent(run.id)}`, { method: 'DELETE' })
navigate('/admin/builder')
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Failed to discard proposal')
setDiscarding(false)
}
}
if (error) {
return (
<div className="mx-auto max-w-4xl py-10">
<div className="border border-red-200 bg-red-50 p-5 text-red-800">
<div className="flex items-center gap-2 font-semibold"><AlertCircle size={18} />Unable to load proposal</div>
<p className="mt-2 text-sm">{error}</p>
</div>
</div>
)
}
if (!run) {
return <div className="flex min-h-72 items-center justify-center text-stone-500"><Loader2 className="mr-2 animate-spin" size={18} />Loading proposal</div>
}
const theme = run.proposal?.theme
const site = run.proposal?.site
const themeValidation = run.validation?.theme
const siteValidation = run.validation?.site
const proposedValidations = [theme ? themeValidation : undefined, site ? siteValidation : undefined].filter(Boolean)
const reviewReady = run.status === 'ready_for_review' && proposedValidations.length > 0 && proposedValidations.every(validation => validation?.valid === true)
const sitePages = Object.values(site?.pages ?? {})
return (
<div className="mx-auto max-w-6xl space-y-6 py-2">
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-stone-200 pb-5">
<div>
<Link to="/admin/builder" className="mb-3 inline-flex items-center gap-1 text-xs font-medium text-stone-500 hover:text-stone-900">
<ArrowLeft size={14} />Back to builder
</Link>
<h2 className="font-serif text-3xl text-stone-950">AI authoring proposal</h2>
<p className="mt-1 text-sm text-stone-600">Review generated themes and pages before any production content changes.</p>
</div>
<div className={`flex items-center gap-2 border px-3 py-2 text-sm font-medium ${reviewReady ? 'border-emerald-200 bg-emerald-50 text-emerald-800' : 'border-amber-200 bg-amber-50 text-amber-800'}`}>
{reviewReady ? <CheckCircle2 size={16} /> : <AlertCircle size={16} />}
{run.status.replaceAll('_', ' ')}
</div>
</div>
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_300px]">
<section className="space-y-5">
<div className="border border-stone-200 bg-white p-5">
<div className="flex items-center justify-between gap-4">
<h3 className="text-sm font-semibold text-stone-950">Theme proposal</h3>
<span className="text-xs text-stone-500">Revision {run.proposalRevision ?? 0}</span>
</div>
{theme ? (
<>
<div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
{colorTokens.map(token => {
const value = theme[token]
return typeof value === 'string' ? (
<div key={token} className="border border-stone-200 p-2">
<div className="aspect-[3/2] border border-black/10" style={{ backgroundColor: value }} />
<p className="mt-2 truncate text-xs font-medium text-stone-700">{token}</p>
<p className="font-mono text-xs text-stone-500">{value}</p>
</div>
) : null
})}
</div>
<div className="mt-5 grid gap-3 border-t border-stone-200 pt-4 sm:grid-cols-2">
<div><p className="text-xs text-stone-500">Typeface</p><p className="mt-1 text-sm text-stone-900">{theme.fontFamily ?? 'Not supplied'}</p></div>
<div><p className="text-xs text-stone-500">Corner radius</p><p className="mt-1 text-sm text-stone-900">{theme.radius ?? 0}rem</p></div>
</div>
</>
) : <p className="mt-4 text-sm text-stone-500">No theme has been proposed yet.</p>}
</div>
{site ? (
<div className="border border-stone-200 bg-white p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-stone-950">Site proposal</h3>
<p className="mt-1 text-sm text-stone-600">{site.structure?.title ?? 'Untitled site'}</p>
</div>
<span className="text-xs text-stone-500">{sitePages.length} {sitePages.length === 1 ? 'page' : 'pages'}</span>
</div>
{site.structure?.description ? <p className="mt-3 max-w-3xl text-sm leading-6 text-stone-600">{site.structure.description}</p> : null}
<div className="mt-4 divide-y divide-stone-200 border-y border-stone-200">
{sitePages.map((page, index) => (
<div key={page.id ?? `${page.slug}-${index}`} className="grid gap-2 py-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_100px] sm:items-center">
<div className="flex min-w-0 items-center gap-2 text-sm font-medium text-stone-900"><FileText className="shrink-0 text-stone-400" size={15} /><span className="truncate">{page.name ?? 'Untitled page'}</span></div>
<span className="truncate font-mono text-xs text-stone-500">/{page.slug ?? ''}</span>
<span className="text-xs text-stone-500 sm:text-right">{page.blocks?.length ?? 0} blocks</span>
</div>
))}
</div>
{site.structure?.navigation?.length ? (
<div className="mt-4">
<p className="text-xs font-medium uppercase text-stone-500">Navigation</p>
<p className="mt-2 text-sm text-stone-700">{site.structure.navigation.map(item => item.label ?? item.pageId).join(' / ')}</p>
</div>
) : null}
</div>
) : null}
<div className="border border-stone-200 bg-white p-5">
<h3 className="text-sm font-semibold text-stone-950">Validation</h3>
{!themeValidation && !siteValidation ? <p className="mt-3 text-sm text-stone-500">Validation has not run.</p> : null}
{([['Theme', themeValidation], ['Site', siteValidation]] as const).map(([label, validation]) => validation ? (
<div key={label} className="mt-3 space-y-2 border-t border-stone-100 pt-3 first:border-0 first:pt-0">
<p className="text-xs font-semibold uppercase text-stone-500">{label}</p>
{validation.valid ? <p className="flex items-center gap-2 text-sm text-emerald-700"><CheckCircle2 size={16} />{label} validation passed.</p> : null}
{validation.errors?.map(message => <p key={message} className="flex items-start gap-2 text-sm text-red-700"><AlertCircle className="mt-0.5 shrink-0" size={15} />{message}</p>)}
{validation.warnings?.map(message => <p key={message} className="flex items-start gap-2 text-sm text-amber-700"><AlertCircle className="mt-0.5 shrink-0" size={15} />{message}</p>)}
</div>
) : null)}
</div>
</section>
<aside className="space-y-4">
<div className="border border-stone-200 bg-stone-50 p-4">
<h3 className="text-sm font-semibold text-stone-950">Run details</h3>
<dl className="mt-3 space-y-3 text-xs">
<div><dt className="text-stone-500">Run ID</dt><dd className="mt-1 break-all font-mono text-stone-800">{run.id}</dd></div>
<div><dt className="text-stone-500">Last updated</dt><dd className="mt-1 text-stone-800">{run.updatedAt ? new Date(run.updatedAt).toLocaleString() : 'Unknown'}</dd></div>
</dl>
</div>
<Link to="/admin/builder" className="flex w-full items-center justify-center gap-2 bg-stone-900 px-4 py-2.5 text-sm font-semibold text-white hover:bg-stone-800">
<ExternalLink size={15} />Open builder
</Link>
<button type="button" onClick={discard} disabled={discarding || run.status === 'applied' || run.status === 'published'} className="flex w-full items-center justify-center gap-2 border border-red-200 bg-white px-4 py-2.5 text-sm font-semibold text-red-700 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-50">
{discarding ? <Loader2 className="animate-spin" size={15} /> : <Trash2 size={15} />}Discard proposal
</button>
</aside>
</div>
</div>
)
}

View File

@ -0,0 +1,188 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router'
import { ArrowRight, CalendarDays, Tag } from 'lucide-react'
import { ApiError } from '../api/client'
import { fetchPublicBlogCategories, fetchPublicBlogPosts, fetchPublicBlogTags, type BlogPost, type BlogTaxonomy } from '../blog'
import { applyBlogSeo } from '../blog-seo'
import { normalizeToken, postMatchesFilters, resolveTaxonomyLabel, summarizePost, taxonomyToken } from '../blog-utils'
type LoadState = 'loading' | 'ready' | 'error'
function formatDate(value?: number): string {
if (!value) return 'Recently'
return new Date(value).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
}
function blogTarget(post: BlogPost): string {
const linked = (post.contentPagePath ?? '').trim()
if (linked.startsWith('/')) {
return linked
}
return `/blog/${post.slug}`
}
export function BlogIndexPage() {
const params = useParams()
const routeCategory = params.categorySlug ? normalizeToken(params.categorySlug) : ''
const routeTag = params.tagSlug ? normalizeToken(params.tagSlug) : ''
const [posts, setPosts] = useState<BlogPost[]>([])
const [categories, setCategories] = useState<BlogTaxonomy[]>([])
const [tags, setTags] = useState<BlogTaxonomy[]>([])
const [state, setState] = useState<LoadState>('loading')
const [error, setError] = useState('')
useEffect(() => {
applyBlogSeo({
title: 'Blog | CMS Platform',
description: 'Insights, release notes, and implementation updates from the CMS platform team.',
canonicalUrl: `${window.location.origin}/blog`,
})
}, [])
useEffect(() => {
let cancelled = false
const load = async () => {
try {
setState('loading')
const [loadedPosts, loadedCategories, loadedTags] = await Promise.all([
fetchPublicBlogPosts(),
fetchPublicBlogCategories(),
fetchPublicBlogTags(),
])
if (cancelled) return
setPosts(loadedPosts)
setCategories(loadedCategories)
setTags(loadedTags)
setState('ready')
} catch (err) {
if (cancelled) return
const message = err instanceof ApiError ? err.message : 'Failed to load blog content'
setError(message)
setState('error')
}
}
void load()
return () => { cancelled = true }
}, [])
const filteredPosts = useMemo(() => {
return posts.filter(post => postMatchesFilters(post, routeCategory || undefined, routeTag || undefined))
}, [posts, routeCategory, routeTag])
const routeTitle = useMemo(() => {
if (routeCategory) {
return `Category: ${resolveTaxonomyLabel(routeCategory, categories)}`
}
if (routeTag) {
return `Tag: ${resolveTaxonomyLabel(routeTag, tags)}`
}
return 'Latest Articles'
}, [routeCategory, routeTag, categories, tags])
return (
<main data-testid="blog-index-page" className="min-h-screen bg-gradient-to-b from-amber-50 via-stone-50 to-white text-stone-900">
<div className="mx-auto max-w-6xl px-4 py-10 md:px-6 md:py-16">
<header className="mb-10 grid gap-4 md:grid-cols-[2fr_1fr] md:items-end">
<div>
<p className="mb-3 inline-flex items-center rounded-full border border-amber-300 bg-amber-100 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-amber-800">
Editorial Hub
</p>
<h1 className="text-3xl font-semibold leading-tight tracking-tight md:text-5xl">{routeTitle}</h1>
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-stone-600 md:text-base">
Published product stories, release notes, and technical deep dives from the CMS team.
</p>
</div>
<div className="rounded-2xl border border-stone-200 bg-white/80 p-4 backdrop-blur">
<p className="text-xs uppercase tracking-[0.18em] text-stone-500">Explore</p>
<div className="mt-3 flex flex-wrap gap-2">
<Link data-testid="blog-filter-all" to="/blog" className="rounded-full border border-stone-300 px-3 py-1 text-xs font-medium text-stone-700 hover:border-stone-900 hover:text-stone-900">
All
</Link>
{categories.slice(0, 6).map(category => (
<Link
key={category.id || category.name}
data-testid={`blog-filter-category-${taxonomyToken(category)}`}
to={`/blog/category/${taxonomyToken(category)}`}
className="rounded-full border border-stone-300 px-3 py-1 text-xs font-medium text-stone-700 hover:border-stone-900 hover:text-stone-900"
>
{category.name}
</Link>
))}
</div>
</div>
</header>
{state === 'loading' && <p className="text-sm text-stone-600">Loading blog posts...</p>}
{state === 'error' && <p className="text-sm text-red-600">{error}</p>}
{state === 'ready' && (
<div className="grid gap-6 lg:grid-cols-[1fr_260px]">
<section className="space-y-4">
{filteredPosts.length === 0 && (
<div className="rounded-2xl border border-stone-200 bg-white p-8 text-sm text-stone-600">
No published posts matched this filter.
</div>
)}
{filteredPosts.map(post => {
const target = blogTarget(post)
return (
<article data-testid={`blog-card-${post.slug}`} key={post.id || post.slug} className="group rounded-2xl border border-stone-200 bg-white p-6 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md">
<div className="mb-4 flex flex-wrap items-center gap-3 text-xs text-stone-500">
<span className="inline-flex items-center gap-1">
<CalendarDays size={14} />
{formatDate(post.publishedAt || post.updatedAt || post.createdAt)}
</span>
{(post.categories ?? []).slice(0, 2).map(category => (
<Link data-testid={`blog-card-category-${normalizeToken(category)}`} key={category} to={`/blog/category/${normalizeToken(category)}`} className="rounded-full bg-stone-100 px-2 py-1 font-medium text-stone-700 hover:bg-stone-200">
{resolveTaxonomyLabel(category, categories)}
</Link>
))}
</div>
<h2 className="text-2xl font-semibold tracking-tight text-stone-900">
<Link data-testid={`blog-open-post-${post.slug}`} to={target} className="hover:text-amber-700">{post.title}</Link>
</h2>
<p className="mt-3 text-sm leading-relaxed text-stone-600 md:text-base">{summarizePost(post) || 'Read the full article.'}</p>
<Link
data-testid={`blog-read-more-${post.slug}`}
to={target}
className="mt-5 inline-flex items-center gap-1 text-sm font-semibold text-amber-700 transition group-hover:gap-2"
>
{target.startsWith('/blog/') ? 'Read article' : 'Open content page'}
<ArrowRight size={16} />
</Link>
</article>
)
})}
</section>
<aside className="h-fit rounded-2xl border border-stone-200 bg-white p-5">
<h3 className="text-xs font-semibold uppercase tracking-[0.15em] text-stone-500">Tags</h3>
<div className="mt-3 flex flex-wrap gap-2">
{tags.length === 0 && <span className="text-sm text-stone-500">No tags yet</span>}
{tags.map(tag => (
<Link
key={tag.id || tag.name}
data-testid={`blog-filter-tag-${taxonomyToken(tag)}`}
to={`/blog/tag/${taxonomyToken(tag)}`}
className="inline-flex items-center gap-1 rounded-full border border-stone-300 px-3 py-1 text-xs font-medium text-stone-700 hover:border-stone-900 hover:text-stone-900"
>
<Tag size={12} />
{tag.name}
</Link>
))}
</div>
</aside>
</div>
)}
</div>
</main>
)
}

View File

@ -0,0 +1,166 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router'
import { ArrowLeft, CalendarDays } from 'lucide-react'
import { ApiError } from '../api/client'
import { fetchPublicBlogPostBySlug, type BlogPost } from '../blog'
import { applyBlogSeo } from '../blog-seo'
import { normalizeToken } from '../blog-utils'
type LoadState = 'loading' | 'ready' | 'error' | 'not-found'
function formatDate(value?: number): string {
if (!value) return 'Recently'
return new Date(value).toLocaleDateString(undefined, { month: 'long', day: 'numeric', year: 'numeric' })
}
function renderContent(content?: string): string[] {
if (!content?.trim()) return []
return content
.split(/\n{2,}/)
.map(part => part.trim())
.filter(Boolean)
}
function linkedContentTarget(post: BlogPost | null): string | null {
const target = (post?.contentPagePath ?? '').trim()
if (!target.startsWith('/')) {
return null
}
if (target.startsWith('/blog/')) {
return null
}
return target
}
export function BlogPostPage() {
const { slug = '' } = useParams()
const [post, setPost] = useState<BlogPost | null>(null)
const [state, setState] = useState<LoadState>('loading')
const [error, setError] = useState('')
useEffect(() => {
let cancelled = false
const load = async () => {
try {
setState('loading')
const loaded = await fetchPublicBlogPostBySlug(slug)
if (cancelled) return
if (!loaded.slug || !loaded.title) {
setState('not-found')
return
}
setPost(loaded)
setState('ready')
} catch (err) {
if (cancelled) return
if (err instanceof ApiError && err.status === 404) {
setState('not-found')
return
}
setError(err instanceof Error ? err.message : 'Unable to load blog post')
setState('error')
}
}
void load()
return () => { cancelled = true }
}, [slug])
useEffect(() => {
if (!post) return
applyBlogSeo({
title: post.seo?.title || `${post.title} | CMS Blog`,
description: post.seo?.description || post.excerpt || undefined,
canonicalUrl: post.seo?.canonicalUrl || `${window.location.origin}/blog/${post.slug}`,
})
}, [post])
const paragraphs = useMemo(() => renderContent(post?.content), [post])
const contentTarget = useMemo(() => linkedContentTarget(post), [post])
if (state === 'loading') {
return <main className="mx-auto max-w-3xl px-4 py-14 text-sm text-stone-600">Loading article...</main>
}
if (state === 'not-found') {
return (
<main className="mx-auto max-w-3xl px-4 py-14">
<h1 className="text-3xl font-semibold text-stone-900">Article not found</h1>
<p className="mt-2 text-sm text-stone-600">This post may still be a draft or has been removed.</p>
<Link to="/blog" className="mt-5 inline-flex items-center gap-2 text-sm font-semibold text-amber-700">
<ArrowLeft size={16} />
Back to blog
</Link>
</main>
)
}
if (state === 'error') {
return <main className="mx-auto max-w-3xl px-4 py-14 text-sm text-red-600">{error}</main>
}
return (
<main data-testid="blog-post-page" className="min-h-screen bg-gradient-to-b from-white via-stone-50 to-amber-50">
<article className="mx-auto max-w-3xl px-4 py-10 md:py-16">
<Link data-testid="blog-post-back-to-index" to="/blog" className="mb-8 inline-flex items-center gap-2 text-sm font-semibold text-stone-600 hover:text-stone-900">
<ArrowLeft size={16} />
All articles
</Link>
<header className="rounded-3xl border border-stone-200 bg-white p-6 shadow-sm md:p-8">
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs text-stone-500">
<span className="inline-flex items-center gap-1">
<CalendarDays size={13} />
{formatDate(post?.publishedAt || post?.updatedAt || post?.createdAt)}
</span>
{(post?.categories ?? []).map(category => (
<Link data-testid={`blog-post-category-${normalizeToken(category)}`} key={category} to={`/blog/category/${normalizeToken(category)}`} className="rounded-full bg-stone-100 px-2.5 py-1 font-medium text-stone-700 hover:bg-stone-200">
{category}
</Link>
))}
</div>
<h1 className="text-3xl font-semibold leading-tight tracking-tight text-stone-900 md:text-5xl">{post?.title}</h1>
{post?.excerpt && <p className="mt-4 text-base leading-relaxed text-stone-600">{post.excerpt}</p>}
</header>
<section className="mt-8 space-y-5 rounded-3xl border border-stone-200 bg-white p-6 text-base leading-relaxed text-stone-700 shadow-sm md:p-8">
{paragraphs.length > 0 ? (
paragraphs.map(paragraph => <p key={paragraph.slice(0, 40)}>{paragraph}</p>)
) : (
<p>{post?.excerpt || 'This article has no body content yet.'}</p>
)}
{contentTarget ? (
<div className="pt-2">
<Link
to={contentTarget}
className="inline-flex items-center gap-2 rounded-full border border-amber-300 bg-amber-50 px-4 py-2 text-sm font-semibold text-amber-800 hover:border-amber-400"
>
Open linked content page
</Link>
</div>
) : null}
</section>
{(post?.tags ?? []).length > 0 && (
<footer className="mt-7 flex flex-wrap items-center gap-2">
{(post?.tags ?? []).map(tag => (
<Link
key={tag}
data-testid={`blog-post-tag-${normalizeToken(tag)}`}
to={`/blog/tag/${normalizeToken(tag)}`}
className="rounded-full border border-stone-300 px-3 py-1 text-xs font-semibold uppercase tracking-[0.08em] text-stone-600 hover:border-stone-900 hover:text-stone-900"
>
{tag}
</Link>
))}
</footer>
)}
</article>
</main>
)
}

View File

@ -0,0 +1,687 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
import { DndProvider } from 'react-dnd'
import { HTML5Backend } from 'react-dnd-html5-backend'
import { Layers, Palette, FileCode2, Trash2, Eye, Save, BookOpen, Check, Database, Download, Globe2, LogOut, Menu, X } from 'lucide-react'
import { Link, useLocation } from 'react-router'
import { BlockLibrary } from '../components/BlockLibrary'
import { Canvas } from '../components/Canvas'
import { BlockEditor } from '../components/BlockEditor'
import { PageSettingsPanel } from '../components/PageSettingsPanel'
import { PreviewModal } from '../components/PreviewModal'
import { PagesModal } from '../components/PagesModal'
import { DataSourceManager } from '../components/DataSourceManager'
import { McpPanel, McpTriggerButton } from '../components/McpPanel'
import { BLOCKS } from '../components/blocks'
import type { ThemeConfig, CanvasBlock, CustomBlockDef, Page, DataSource, DataCacheEntry, PageSEO } from '../types'
import {
findBlockInTree, updateBlockInTree, removeBlockFromTree, moveBlockInTree,
loadPages, savePage as savePageResource, deletePage as deletePageResource, loadDataSources, saveDataSources, fetchDataSource,
DEFAULT_THEME, PRESET_THEMES,
} from '../cms-utils'
import { createEmptyPageSEO, normalizePageSEO, normalizePageSlug, sanitizePageSEO } from '../page-seo'
import { EventBus } from '../mcp/EventBus'
import { currentUser, logout } from '../auth/keycloak'
import { getHomeNavItems, trackHomeNavEvent } from '../home-nav'
import { fetchSitemapMenus, toHomeNavItems } from '../sitemap-menu'
import { generatePageHtml } from '../page-renderer'
export { PRESET_THEMES }
type CmsBuilderPageProps = {
embedded?: boolean
}
function sameNavItems(
left: Array<{ id: string; label: string; to: string }>,
right: Array<{ id: string; label: string; to: string }>,
): boolean {
if (left.length !== right.length) return false
for (let i = 0; i < left.length; i += 1) {
if (left[i]?.id !== right[i]?.id || left[i]?.label !== right[i]?.label || left[i]?.to !== right[i]?.to) {
return false
}
}
return true
}
function normalizeLoadedPage(raw: Page): Page {
const source = (raw ?? {}) as Partial<Page> & Record<string, unknown>
const now = Date.now()
const blocks = Array.isArray(source.blocks) ? source.blocks : []
const customBlocks = Array.isArray(source.customBlocks) ? source.customBlocks : []
const createdAt = typeof source.createdAt === 'number' && Number.isFinite(source.createdAt) ? source.createdAt : now
const updatedAt = typeof source.updatedAt === 'number' && Number.isFinite(source.updatedAt) ? source.updatedAt : createdAt
return {
id: typeof source.id === 'string' ? source.id : '',
name: typeof source.name === 'string' && source.name.trim() ? source.name : (typeof source.title === 'string' && source.title.trim() ? source.title : 'Untitled page'),
...(typeof source.slug === 'string' && source.slug.trim() ? { slug: source.slug } : {}),
...(source.status === 'published' ? { status: 'published' as const } : { status: 'draft' as const }),
...(typeof source.publishedAt === 'number' && Number.isFinite(source.publishedAt) ? { publishedAt: source.publishedAt } : {}),
...(source.seo ? { seo: normalizePageSEO(source.seo) } : {}),
blocks,
theme: source.theme && typeof source.theme === 'object' ? source.theme as ThemeConfig : DEFAULT_THEME,
customBlocks,
createdAt,
updatedAt,
}
}
export function CmsBuilderPage({ embedded = false }: CmsBuilderPageProps) {
const location = useLocation()
const [theme, setTheme] = useState<ThemeConfig>(DEFAULT_THEME)
const [blocks, setBlocks] = useState<CanvasBlock[]>([])
const [selectedId, setSelectedId] = useState<string | null>(null)
const [activePanel, setActivePanel] = useState<'blocks' | 'theme'>('blocks')
const [previewing, setPreviewing] = useState(false)
const [customBlocks, setCustomBlocks] = useState<CustomBlockDef[]>([])
// Page management
const [pages, setPages] = useState<Page[]>([])
const [currentPageId, setCurrentPageId] = useState<string | null>(null)
const [pageName, setPageName] = useState('Untitled page')
const [pageSlug, setPageSlug] = useState('')
const [pageSEO, setPageSEO] = useState<PageSEO>(createEmptyPageSEO())
const [editingPageName, setEditingPageName] = useState(false)
const [showPages, setShowPages] = useState(false)
const [savedFlash, setSavedFlash] = useState(false)
const [publishedFlash, setPublishedFlash] = useState(false)
const [persistenceError, setPersistenceError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [isDirty, setIsDirty] = useState(false)
const pageNameRef = useRef<HTMLInputElement>(null)
// Data sources
const [dataSources, setDataSources] = useState<DataSource[]>([])
const [dataCache, setDataCache] = useState<Record<string, DataCacheEntry>>({})
const [showDataSources, setShowDataSources] = useState(false)
const [showMcp, setShowMcp] = useState(false)
const [mobileNavOpen, setMobileNavOpen] = useState(false)
const [mobilePane, setMobilePane] = useState<'blocks' | 'canvas' | 'settings'>('canvas')
const workspaceRef = useRef<HTMLDivElement>(null)
const user = currentUser()
const roleKey = useMemo(
() => (user.roles ?? []).map(role => role.trim().toLowerCase()).sort().join('|'),
[user.roles],
)
const fallbackNavItems = useMemo(() => getHomeNavItems(user.roles ?? []), [roleKey])
const [navItems, setNavItems] = useState(fallbackNavItems)
useEffect(() => {
setNavItems(prev => (sameNavItems(prev, fallbackNavItems) ? prev : fallbackNavItems))
}, [roleKey, fallbackNavItems])
useEffect(() => {
const roles = user.roles ?? []
const isAdmin = roles.some(role => role.trim().toLowerCase() === 'admin')
if (!isAdmin) {
return
}
let mounted = true
const loadSitemapMenu = async () => {
try {
const menus = await fetchSitemapMenus()
const primary = menus[0]
if (!mounted || !primary || primary.items.length === 0) {
return
}
const configured = toHomeNavItems(primary.items).map(item => ({
id: item.id,
label: item.label,
to: item.to,
}))
if (configured.length > 0) {
setNavItems(prev => (sameNavItems(prev, configured) ? prev : configured))
}
} catch {
// Keep static fallback nav when sitemap menu is not available.
}
}
void loadSitemapMenu()
return () => {
mounted = false
}
}, [roleKey])
useEffect(() => {
let mounted = true
const bootstrap = async () => {
try {
const [pagesResult, dataSourcesResult] = await Promise.allSettled([
loadPages(),
loadDataSources(),
])
if (!mounted) return
if (pagesResult.status === 'fulfilled') {
const loadedPages = pagesResult.value.map(normalizeLoadedPage).filter(page => page.id)
setPages(loadedPages)
// Restore the most recent persisted page so reload reflects saved state.
if (loadedPages.length > 0) {
const firstPage = loadedPages[0]
setBlocks(firstPage.blocks)
setTheme(firstPage.theme)
setCustomBlocks(firstPage.customBlocks)
setPageName(firstPage.name)
setPageSlug(firstPage.slug ?? '')
setPageSEO(normalizePageSEO(firstPage.seo))
setCurrentPageId(firstPage.id)
setSelectedId(null)
}
} else {
console.error('failed to load pages', pagesResult.reason)
}
if (dataSourcesResult.status === 'fulfilled') {
setDataSources(dataSourcesResult.value)
} else {
console.error('failed to load data sources', dataSourcesResult.reason)
}
} catch (error) {
console.error('failed to load cms builder data', error)
}
}
void bootstrap()
return () => { mounted = false }
}, [])
useEffect(() => {
const warnBeforeUnload = (event: BeforeUnloadEvent) => {
if (!isDirty) return
event.preventDefault()
event.returnValue = ''
}
window.addEventListener('beforeunload', warnBeforeUnload)
return () => window.removeEventListener('beforeunload', warnBeforeUnload)
}, [isDirty])
useEffect(() => {
if (!window.matchMedia('(max-width: 1023px)').matches) return
const frame = window.requestAnimationFrame(() => {
const workspace = workspaceRef.current
if (workspace) workspace.scrollLeft = workspace.clientWidth
})
return () => window.cancelAnimationFrame(frame)
}, [])
useEffect(() => {
dataSources.forEach(source => {
setDataCache(prev => ({ ...prev, [source.id]: { data: prev[source.id]?.data ?? [], loading: true, error: null } }))
fetchDataSource(source).then(({ data, error }) => {
setDataCache(prev => ({ ...prev, [source.id]: { data, loading: false, error } }))
})
})
}, [dataSources])
const deleteCustomBlock = useCallback((id: string) => {
setCustomBlocks(prev => prev.filter(b => b.id !== id))
setBlocks(prev => removeBlockFromTree(prev, `custom:${id}`))
setIsDirty(true)
}, [])
const addBlock = useCallback((blockId: string, parentInstanceId?: string, slotIndex?: number) => {
let defaultContent: Record<string, string> = {}
if (!blockId.startsWith('custom:')) {
const def = BLOCKS.find(b => b.id === blockId)
if (def) defaultContent = { ...def.defaultContent }
}
const newBlock: CanvasBlock = {
instanceId: `${blockId}-${Date.now()}`,
blockId, content: defaultContent, styles: {},
}
if (parentInstanceId !== undefined && slotIndex !== undefined) {
setBlocks(prev => updateBlockInTree(prev, parentInstanceId, block => {
const slots = block.slots ? block.slots.map(s => [...s]) : []
while (slots.length <= slotIndex) slots.push([])
slots[slotIndex] = [...(slots[slotIndex] ?? []), newBlock]
return { ...block, slots }
}))
} else {
setBlocks(prev => [...prev, newBlock])
}
setIsDirty(true)
EventBus.emit('cms:block:added', { pageId: currentPageId, block: newBlock, parentId: parentInstanceId, slotIndex })
}, [currentPageId])
const removeBlock = useCallback((instanceId: string) => {
setBlocks(prev => removeBlockFromTree(prev, instanceId))
setSelectedId(null)
setIsDirty(true)
EventBus.emit('cms:block:removed', { pageId: currentPageId, instanceId })
}, [currentPageId])
const moveBlock = useCallback((instanceId: string, direction: 'up' | 'down') => {
setBlocks(prev => moveBlockInTree(prev, instanceId, direction))
setIsDirty(true)
EventBus.emit('cms:block:moved', { pageId: currentPageId, instanceId, direction })
}, [currentPageId])
const updateBlockContent = useCallback((instanceId: string, key: string, value: string) => {
setBlocks(prev => updateBlockInTree(prev, instanceId, b => ({ ...b, content: { ...b.content, [key]: value } })))
setIsDirty(true)
EventBus.emit('cms:block:content', { pageId: currentPageId, instanceId, key, value })
}, [currentPageId])
const updateBlockStyle = useCallback((instanceId: string, key: string, value: string) => {
setBlocks(prev => updateBlockInTree(prev, instanceId, b => ({ ...b, styles: { ...b.styles, [key]: value } })))
setIsDirty(true)
EventBus.emit('cms:block:style', { pageId: currentPageId, instanceId, key, value })
}, [currentPageId])
const resetBlockStyles = useCallback((instanceId: string) => {
setBlocks(prev => updateBlockInTree(prev, instanceId, b => ({ ...b, styles: {} })))
setIsDirty(true)
}, [])
const clearCanvas = () => {
if (!window.confirm('Clear every block from this page? This cannot be undone.')) return
setBlocks([]); setSelectedId(null)
setIsDirty(true)
EventBus.emit('cms:page:cleared', { pageId: currentPageId })
}
const persistPage = useCallback(async (statusOverride?: Page['status']) => {
setSaving(true)
setPersistenceError(null)
const now = Date.now()
const id = currentPageId ?? `page-${now}`
const existingPage = pages.find(page => page.id === id)
const status = statusOverride ?? existingPage?.status ?? 'draft'
const sanitizedSEO = sanitizePageSEO(pageSEO)
const slug = normalizePageSlug(pageSlug)
const page: Page = {
id, name: pageName, blocks, theme, customBlocks,
...(slug ? { slug } : {}),
status,
...(status === 'published' ? { publishedAt: existingPage?.publishedAt ?? now } : {}),
...(sanitizedSEO ? { seo: sanitizedSEO } : {}),
createdAt: existingPage?.createdAt ?? now,
updatedAt: now,
}
try {
const persistedPage = normalizeLoadedPage(await savePageResource(page, Boolean(existingPage), existingPage?.updatedAt))
setCurrentPageId(persistedPage.id)
setPages(previous => existingPage
? previous.map(candidate => candidate.id === persistedPage.id ? persistedPage : candidate)
: [persistedPage, ...previous])
setPageName(persistedPage.name)
setPageSlug(persistedPage.slug ?? '')
setPageSEO(normalizePageSEO(persistedPage.seo))
setIsDirty(false)
setSavedFlash(true)
setTimeout(() => setSavedFlash(false), 1800)
EventBus.emit(currentPageId ? 'cms:page:saved' : 'cms:page:created', { page: persistedPage })
return persistedPage
} catch (error) {
console.error('failed to save pages', error)
setPersistenceError(error instanceof Error ? error.message : 'Failed to save page')
throw error
} finally {
setSaving(false)
}
}, [currentPageId, pageName, pageSlug, pageSEO, blocks, theme, customBlocks, pages])
const savePage = useCallback(() => { void persistPage().catch(() => undefined) }, [persistPage])
const publishPage = useCallback(async () => {
try {
const page = await persistPage('published')
setPublishedFlash(true)
setTimeout(() => setPublishedFlash(false), 1800)
EventBus.emit('cms:page:published', { page })
} catch {
// persistPage exposes the error in the builder toolbar.
}
}, [persistPage])
const downloadPageHtml = useCallback(() => {
const now = Date.now()
const page: Page = {
id: currentPageId ?? `page-${now}`,
name: pageName,
...(normalizePageSlug(pageSlug) ? { slug: normalizePageSlug(pageSlug) } : {}),
seo: sanitizePageSEO(pageSEO),
blocks,
theme,
customBlocks,
createdAt: pages.find(page => page.id === currentPageId)?.createdAt ?? now,
updatedAt: now,
}
const blob = new Blob([generatePageHtml(page, dataCache)], { type: 'text/html;charset=utf-8' })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `${page.slug || 'page'}.html`
anchor.click()
URL.revokeObjectURL(url)
}, [blocks, currentPageId, customBlocks, dataCache, pageName, pageSEO, pageSlug, pages, theme])
const loadPage = useCallback((page: Page) => {
if (isDirty && !window.confirm('Discard unsaved changes and open another page?')) return
const normalized = normalizeLoadedPage(page)
setBlocks(normalized.blocks); setTheme(normalized.theme); setCustomBlocks(normalized.customBlocks)
setPageName(normalized.name); setPageSlug(normalized.slug ?? ''); setPageSEO(normalizePageSEO(normalized.seo)); setCurrentPageId(normalized.id); setSelectedId(null)
setIsDirty(false)
EventBus.emit('cms:page:loaded', { page: normalized })
}, [isDirty])
const newPage = useCallback(() => {
if (isDirty && !window.confirm('Discard unsaved changes and create a new page?')) return
setBlocks([]); setTheme(DEFAULT_THEME); setCustomBlocks([])
setPageName('Untitled page'); setPageSlug(''); setPageSEO(createEmptyPageSEO()); setCurrentPageId(null); setSelectedId(null); setShowPages(false)
setIsDirty(true)
}, [isDirty])
const deletePage = useCallback(async (id: string) => {
const page = pages.find(p => p.id === id)
if (!page) return
setPersistenceError(null)
try {
await deletePageResource(id)
setPages(previous => previous.filter(candidate => candidate.id !== id))
if (currentPageId === id) {
setCurrentPageId(null)
setBlocks([])
setPageName('Untitled page')
setPageSlug('')
setPageSEO(createEmptyPageSEO())
}
EventBus.emit('cms:page:deleted', { pageId: id, name: page.name })
} catch (error) {
setPersistenceError(error instanceof Error ? error.message : 'Failed to delete page')
throw error
}
}, [pages, currentPageId])
const selectedBlock = selectedId ? findBlockInTree(blocks, selectedId) : null
const handleThemeChange = useCallback((t: ThemeConfig) => {
setTheme(t)
setIsDirty(true)
EventBus.emit('cms:theme:changed', { theme: t })
}, [])
const handleSelect = useCallback((id: string | null) => {
setSelectedId(id)
EventBus.emit('cms:block:selected', { instanceId: id })
}, [])
const handleSaveDataSource = useCallback((source: DataSource) => {
setDataSources(prev => {
const exists = prev.find(s => s.id === source.id)
const next = exists ? prev.map(s => s.id === source.id ? source : s) : [source, ...prev]
void saveDataSources(next)
return next
})
EventBus.emit('cms:datasource:saved', { source })
}, [])
const handleDeleteDataSource = useCallback((id: string) => {
setDataSources(prev => { const n = prev.filter(s => s.id !== id); void saveDataSources(n); return n })
setDataCache(prev => { const n = { ...prev }; delete n[id]; return n })
EventBus.emit('cms:datasource:deleted', { sourceId: id })
}, [])
const handleRefreshDataSource = useCallback((id: string) => {
const source = dataSources.find(s => s.id === id)
if (!source) return
setDataCache(prev => ({ ...prev, [id]: { data: prev[id]?.data ?? [], loading: true, error: null } }))
fetchDataSource(source).then(({ data, error }) => {
setDataCache(prev => ({ ...prev, [id]: { data, loading: false, error } }))
})
EventBus.emit('cms:datasource:refreshed', { sourceId: id })
}, [dataSources])
const isNavItemActive = useCallback((to: string) => {
if (to === '/') {
return location.pathname === '/'
}
return location.pathname === to || location.pathname.startsWith(`${to}/`)
}, [location.pathname])
const handleLogout = useCallback(async () => {
trackHomeNavEvent({ action: 'logout', navId: 'logout' })
await logout()
}, [])
const showMobilePane = (pane: 'blocks' | 'canvas' | 'settings') => {
const paneIndex = { blocks: 0, canvas: 1, settings: 2 }[pane]
setMobilePane(pane)
workspaceRef.current?.scrollTo({ left: workspaceRef.current.clientWidth * paneIndex, behavior: 'smooth' })
}
return (
<DndProvider backend={HTML5Backend}>
<div
data-testid="cms-builder-page"
className={`flex flex-col overflow-hidden ${embedded ? 'h-full min-h-0 w-full max-w-full' : 'h-screen w-screen'}`}
>
{/* Top bar */}
<header className="z-30 flex flex-shrink-0 flex-col items-stretch gap-2 border-b border-zinc-200 bg-white p-2 md:h-12 md:flex-row md:items-center md:justify-between md:px-5 md:py-0">
<div className="flex min-w-0 items-center gap-3">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-md bg-indigo-600 flex items-center justify-center">
<FileCode2 size={13} color="#fff" />
</div>
<span className="text-sm font-semibold text-zinc-900">CMS Builder</span>
</div>
{!embedded ? (
<>
<div className="w-px h-4 bg-zinc-200" />
<div className="hidden items-center gap-0.5 bg-zinc-100 rounded-md p-0.5 md:flex">
{navItems.map(item => (
<Link
key={item.id}
data-testid={`home-nav-link-${item.id}`}
to={item.to}
onClick={() => trackHomeNavEvent({ action: 'navigate', navId: item.id, to: item.to })}
className={`px-2.5 py-1 text-xs font-medium rounded transition-colors ${isNavItemActive(item.to) ? 'bg-white text-zinc-900 shadow-sm' : 'text-zinc-500 hover:text-zinc-700'}`}
>
{item.label}
</Link>
))}
</div>
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-zinc-600 hover:bg-zinc-100 md:hidden"
data-testid="home-nav-mobile-toggle"
onClick={() => setMobileNavOpen(open => !open)}
aria-label="Toggle navigation"
>
{mobileNavOpen ? <X size={14} /> : <Menu size={14} />}
</button>
<div className="w-px h-4 bg-zinc-200" />
</>
) : null}
{editingPageName ? (
<input
ref={pageNameRef} value={pageName}
onChange={e => { setPageName(e.target.value); setIsDirty(true) }}
onBlur={() => setEditingPageName(false)}
onKeyDown={e => { if (e.key === 'Enter' || e.key === 'Escape') setEditingPageName(false) }}
autoFocus
className="text-xs font-medium text-zinc-800 border border-indigo-300 rounded px-2 py-0.5 outline-none bg-indigo-50 w-40"
/>
) : (
<button data-testid="cms-page-rename-trigger" onClick={() => setEditingPageName(true)}
className="text-xs text-zinc-600 font-medium hover:text-zinc-900 hover:bg-zinc-100 rounded px-1.5 py-0.5 transition-colors"
title="Click to rename">
{pageName}
</button>
)}
{isDirty ? <span className="text-xs font-medium text-amber-700">Unsaved</span> : null}
</div>
<div className="hidden items-center gap-0.5 rounded-lg bg-zinc-100 p-0.5 md:flex">
<button onClick={() => setActivePanel('blocks')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${activePanel === 'blocks' ? 'bg-white text-zinc-900 shadow-sm' : 'text-zinc-500 hover:text-zinc-700'}`}>
<Layers size={12} />Blocks
</button>
<button onClick={() => setActivePanel('theme')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${activePanel === 'theme' ? 'bg-white text-zinc-900 shadow-sm' : 'text-zinc-500 hover:text-zinc-700'}`}>
<Palette size={12} />Theme
</button>
</div>
<div className="flex items-center gap-2 overflow-x-auto pb-1 md:overflow-visible md:pb-0 [&>*]:shrink-0">
{!embedded ? (
<button
type="button"
data-testid="home-nav-logout"
onClick={() => void handleLogout()}
className="hidden items-center gap-1.5 px-3 py-1.5 text-xs font-medium border border-zinc-200 text-zinc-600 hover:text-zinc-900 hover:border-zinc-300 rounded-md transition-colors md:flex"
>
<LogOut size={12} />Logout
</button>
) : null}
{blocks.length > 0 && (
<button onClick={clearCanvas} className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-zinc-500 hover:text-red-500 border border-zinc-200 hover:border-red-200 rounded-md transition-colors">
<Trash2 size={12} />Clear
</button>
)}
<button data-testid="cms-open-data-sources" onClick={() => setShowDataSources(true)}
className="order-6 flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border border-zinc-200 text-zinc-600 hover:text-zinc-900 hover:border-zinc-300 rounded-md transition-colors md:order-none">
<Database size={12} />Data {dataSources.length > 0 && <span className="ml-0.5 bg-zinc-100 text-zinc-500 rounded-full px-1.5 py-px text-xs">{dataSources.length}</span>}
</button>
<button data-testid="cms-open-pages" onClick={() => setShowPages(true)}
className="order-4 flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border border-zinc-200 text-zinc-600 hover:text-zinc-900 hover:border-zinc-300 rounded-md transition-colors md:order-none">
<BookOpen size={12} />Pages {pages.length > 0 && <span className="ml-0.5 bg-zinc-100 text-zinc-500 rounded-full px-1.5 py-px text-xs">{pages.length}</span>}
</button>
{persistenceError ? <p role="alert" className="max-w-56 truncate text-xs font-medium text-red-600" title={persistenceError}>{persistenceError}</p> : null}
<button data-testid="cms-save-page" onClick={savePage} disabled={saving}
className={`order-1 flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-all md:order-none ${savedFlash ? 'bg-green-500 text-white' : 'bg-zinc-800 hover:bg-zinc-900 text-white'}`}>
{savedFlash ? <><Check size={12} />Saved</> : <><Save size={12} />{saving ? 'Saving' : 'Save'}</>}
</button>
<button data-testid="cms-download-html" onClick={downloadPageHtml} title="Download standalone HTML"
className="order-5 flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border border-zinc-200 text-zinc-600 hover:text-zinc-900 hover:border-zinc-300 rounded-md transition-colors md:order-none">
<Download size={12} />HTML
</button>
<button data-testid="cms-publish-page" onClick={() => void publishPage()} disabled={saving}
className={`order-3 flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-all md:order-none ${publishedFlash ? 'bg-green-500 text-white' : 'bg-emerald-600 hover:bg-emerald-700 text-white'}`}>
{publishedFlash ? <><Check size={12} />Published</> : <><Globe2 size={12} />Publish</>}
</button>
<button data-testid="cms-open-preview" onClick={() => { setPreviewing(true); EventBus.emit('cms:preview:opened', { pageId: currentPageId }) }}
className="order-2 flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-indigo-600 hover:bg-indigo-700 text-white rounded-md transition-colors md:order-none">
<Eye size={12} />Preview
</button>
<div className="order-7 md:order-none"><McpTriggerButton onClick={() => setShowMcp(p => !p)} /></div>
<div className="order-8 flex items-center gap-1 bg-zinc-100 rounded-full px-2 py-1 md:order-none">
{[theme.primary, theme.muted, theme.accent].map((color, i) => (
<div key={i} style={{ width: 12, height: 12, borderRadius: '50%', background: /^#[0-9a-fA-F]{6}$/.test(color) ? color : '#888', border: '1px solid rgba(0,0,0,0.1)' }} />
))}
</div>
</div>
</header>
<div className="grid grid-cols-3 border-b border-zinc-200 bg-white p-1 lg:hidden" aria-label="Builder workspace pane">
{(['blocks', 'canvas', 'settings'] as const).map(pane => (
<button
key={pane}
type="button"
onClick={() => showMobilePane(pane)}
className={`rounded-md px-3 py-2 text-xs font-medium capitalize ${mobilePane === pane ? 'bg-zinc-900 text-white' : 'text-zinc-600'}`}
aria-pressed={mobilePane === pane}
>
{pane}
</button>
))}
</div>
{!embedded && mobileNavOpen && (
<div className="border-b border-zinc-200 bg-white px-5 py-2 md:hidden">
<div className="grid grid-cols-2 gap-1">
{navItems.map(item => (
<Link
key={item.id}
data-testid={`home-nav-mobile-link-${item.id}`}
to={item.to}
onClick={() => {
trackHomeNavEvent({ action: 'navigate', navId: item.id, to: item.to })
setMobileNavOpen(false)
}}
className={`rounded-md px-2.5 py-2 text-xs font-medium ${isNavItemActive(item.to) ? 'bg-zinc-900 text-white' : 'bg-zinc-100 text-zinc-700'}`}
>
{item.label}
</Link>
))}
<button
type="button"
data-testid="home-nav-mobile-logout"
onClick={() => void handleLogout()}
className="col-span-2 inline-flex items-center justify-center gap-1 rounded-md border border-zinc-200 px-2.5 py-2 text-xs font-medium text-zinc-700"
>
<LogOut size={12} />Logout {user.username ? `(${user.username})` : ''}
</button>
</div>
</div>
)}
<div ref={workspaceRef} className="flex flex-1 snap-x snap-mandatory overflow-x-auto overflow-y-hidden lg:snap-none lg:overflow-hidden">
<div className="flex w-full flex-shrink-0 snap-start flex-col overflow-hidden border-r border-zinc-200 bg-white lg:w-56 2xl:w-60">
{activePanel === 'blocks' ? (
<BlockLibrary
onAddBlock={addBlock} customBlocks={customBlocks}
onDeleteCustomBlock={deleteCustomBlock}
/>
) : (
<div className="flex-1 flex items-center justify-center text-zinc-400 text-sm">Switch to Blocks panel</div>
)}
</div>
<main aria-label="Page canvas" className="w-full flex-shrink-0 snap-start overflow-auto lg:min-w-0 lg:flex-1">
<Canvas
blocks={blocks} customBlocks={customBlocks} theme={theme}
selectedId={selectedId} dataSources={dataSources} dataCache={dataCache}
onSelect={handleSelect} onDrop={addBlock} onRemove={removeBlock} onMove={moveBlock}
/>
</main>
<div className="flex w-full flex-shrink-0 snap-start flex-col overflow-hidden border-l border-zinc-200 bg-white lg:w-64 2xl:w-72">
{selectedBlock ? (
<BlockEditor
block={selectedBlock} customBlocks={customBlocks} theme={theme} dataSources={dataSources}
onContentChange={updateBlockContent} onStyleChange={updateBlockStyle}
onStyleReset={resetBlockStyles} onThemeChange={handleThemeChange} onDeselect={() => handleSelect(null)}
/>
) : (
<PageSettingsPanel
pageName={pageName}
slug={pageSlug}
seo={pageSEO}
theme={theme}
onSlugChange={value => { setPageSlug(value); setIsDirty(true) }}
onSEOChange={value => { setPageSEO(value); setIsDirty(true) }}
onThemeChange={handleThemeChange}
/>
)}
</div>
</div>
</div>
{previewing && (
<PreviewModal blocks={blocks} customBlocks={customBlocks} theme={theme} dataCache={dataCache}
onClose={() => { setPreviewing(false); EventBus.emit('cms:preview:closed', { pageId: currentPageId }) }} />
)}
{showPages && (
<PagesModal pages={pages} currentPageId={currentPageId} onLoad={loadPage} onNew={newPage} onDelete={deletePage} onClose={() => setShowPages(false)} />
)}
{showDataSources && (
<DataSourceManager dataSources={dataSources} dataCache={dataCache}
onSave={handleSaveDataSource} onDelete={handleDeleteDataSource} onRefresh={handleRefreshDataSource}
onClose={() => setShowDataSources(false)} />
)}
{showMcp && <McpPanel onClose={() => setShowMcp(false)} />}
</DndProvider>
)
}

View File

@ -0,0 +1,14 @@
type FeatureGuardPageProps = {
title: string
summary: string
}
export function FeatureGuardPage({ title, summary }: FeatureGuardPageProps) {
return (
<section className="rounded-2xl border border-stone-200 bg-white p-6 shadow-sm" data-testid="feature-not-enabled">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-stone-500">Coming soon</p>
<h1 className="mt-2 text-xl font-semibold tracking-tight text-stone-950">{title}</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-stone-600">{summary}</p>
</section>
)
}

View File

@ -0,0 +1,530 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Plus, Save, Trash2, ClipboardList } from 'lucide-react'
import { createResource, deleteResource, listResource, patchResource } from '../api/client'
import { fetchAdminFormSubmissions, submissionsToCSV, type FormSubmissionRecord } from '../public-forms'
type FieldType = 'text' | 'textarea' | 'email' | 'number' | 'select' | 'checkbox'
type FormStatus = 'draft' | 'published'
type FormField = {
name: string
type: FieldType
label?: string
required: boolean
options?: string[]
}
type FormRecord = {
id: string
name: string
description?: string
status: FormStatus
schema: FormField[]
createdAt?: number
updatedAt?: number
}
type FormEditorState = {
id?: string
name: string
description: string
status: FormStatus
schema: FormField[]
}
const EMPTY_FIELD: FormField = {
name: '',
type: 'text',
label: '',
required: false,
options: [],
}
const EMPTY_FORM: FormEditorState = {
name: '',
description: '',
status: 'draft',
schema: [EMPTY_FIELD],
}
function normalizeField(raw: unknown): FormField {
const source = (raw ?? {}) as Record<string, unknown>
const type = String(source.type ?? 'text').trim().toLowerCase() as FieldType
const normalizedType: FieldType = ['text', 'textarea', 'email', 'number', 'select', 'checkbox'].includes(type) ? type : 'text'
return {
name: String(source.name ?? '').trim(),
type: normalizedType,
label: String(source.label ?? '').trim(),
required: Boolean(source.required),
options: Array.isArray(source.options) ? source.options.map(value => String(value).trim()).filter(Boolean) : [],
}
}
function normalizeForm(raw: unknown): FormRecord {
const source = (raw ?? {}) as Record<string, unknown>
const schema = Array.isArray(source.schema) ? source.schema.map(normalizeField) : []
return {
id: String(source.id ?? ''),
name: String(source.name ?? '').trim(),
description: String(source.description ?? '').trim(),
status: String(source.status ?? 'draft').trim().toLowerCase() === 'published' ? 'published' : 'draft',
schema,
createdAt: typeof source.createdAt === 'number' ? source.createdAt : undefined,
updatedAt: typeof source.updatedAt === 'number' ? source.updatedAt : undefined,
}
}
function toEditorState(form: FormRecord): FormEditorState {
return {
id: form.id,
name: form.name,
description: form.description ?? '',
status: form.status,
schema: form.schema.length > 0 ? form.schema : [EMPTY_FIELD],
}
}
function formatDate(value?: number): string {
if (!value) {
return '-'
}
return new Date(value).toLocaleString()
}
export function FormsBuilderPage() {
const [forms, setForms] = useState<FormRecord[]>([])
const [selectedId, setSelectedId] = useState<string | null>(null)
const [editor, setEditor] = useState<FormEditorState>(EMPTY_FORM)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [notice, setNotice] = useState<string | null>(null)
const [submissions, setSubmissions] = useState<FormSubmissionRecord[]>([])
const [submissionsLoading, setSubmissionsLoading] = useState(false)
const loadSubmissions = useCallback(async (formID: string) => {
setSubmissionsLoading(true)
try {
const items = await fetchAdminFormSubmissions(formID)
setSubmissions(items)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load submissions')
} finally {
setSubmissionsLoading(false)
}
}, [])
const sortedForms = useMemo(
() => [...forms].sort((a, b) => (b.updatedAt ?? b.createdAt ?? 0) - (a.updatedAt ?? a.createdAt ?? 0)),
[forms],
)
const selected = useMemo(
() => (selectedId ? forms.find(form => form.id === selectedId) ?? null : null),
[forms, selectedId],
)
const loadForms = useCallback(async () => {
setLoading(true)
setError(null)
try {
const records = await listResource<unknown>('forms')
const normalized = records.map(normalizeForm).filter(form => form.id)
setForms(normalized)
if (selectedId) {
const active = normalized.find(form => form.id === selectedId)
if (active) {
setEditor(toEditorState(active))
}
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load forms')
} finally {
setLoading(false)
}
}, [selectedId])
useEffect(() => {
void loadForms()
}, [loadForms])
const handleSelectForm = (form: FormRecord) => {
setSelectedId(form.id)
setEditor(toEditorState(form))
setError(null)
setNotice(null)
void loadSubmissions(form.id)
}
const handleNewForm = () => {
setSelectedId(null)
setEditor(EMPTY_FORM)
setError(null)
setNotice(null)
setSubmissions([])
}
const updateField = (index: number, patch: Partial<FormField>) => {
setEditor(prev => ({
...prev,
schema: prev.schema.map((field, i) => (i === index ? { ...field, ...patch } : field)),
}))
}
const addField = () => {
setEditor(prev => ({ ...prev, schema: [...prev.schema, { ...EMPTY_FIELD }] }))
}
const removeField = (index: number) => {
setEditor(prev => {
const next = prev.schema.filter((_, i) => i !== index)
return { ...prev, schema: next.length > 0 ? next : [{ ...EMPTY_FIELD }] }
})
}
const saveForm = async () => {
setSaving(true)
setError(null)
setNotice(null)
const schema = editor.schema
.map(field => ({
name: field.name.trim(),
type: field.type,
label: field.label?.trim() ?? '',
required: Boolean(field.required),
options: (field.options ?? []).map(option => option.trim()).filter(Boolean),
}))
.filter(field => field.name)
const payload = {
name: editor.name.trim(),
description: editor.description.trim(),
status: editor.status,
schema,
}
try {
if (editor.id) {
await patchResource('forms', editor.id, payload as unknown as Record<string, unknown>)
setNotice('Form updated')
} else {
const created = await createResource<{ id: string }>('forms', payload as unknown as Record<string, unknown>)
setSelectedId(created.id)
setNotice('Form created')
}
await loadForms()
if (editor.id) {
await loadSubmissions(editor.id)
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save form')
} finally {
setSaving(false)
}
}
const removeSelectedForm = async () => {
if (!selectedId) {
return
}
setSaving(true)
setError(null)
setNotice(null)
try {
await deleteResource('forms', selectedId)
setSelectedId(null)
setEditor(EMPTY_FORM)
setSubmissions([])
setNotice('Form deleted')
await loadForms()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete form')
} finally {
setSaving(false)
}
}
const exportSubmissions = () => {
if (!selectedId || submissions.length === 0) {
setNotice('No submissions to export')
return
}
const csv = submissionsToCSV(submissions)
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `${selectedId}-submissions.csv`
document.body.appendChild(anchor)
anchor.click()
document.body.removeChild(anchor)
URL.revokeObjectURL(url)
setNotice('Submissions exported')
}
return (
<main className="min-h-0 p-4 md:p-6">
<div className="mx-auto grid max-w-7xl gap-4 lg:grid-cols-[320px_1fr]">
<aside className="rounded-xl border border-zinc-200 bg-white shadow-sm">
<div className="flex items-center justify-between border-b border-zinc-200 px-4 py-3">
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-900">
<ClipboardList size={16} />
Forms
</div>
<button
type="button"
onClick={handleNewForm}
className="inline-flex items-center gap-1 rounded-md border border-zinc-200 px-2 py-1 text-xs font-medium text-zinc-700 hover:bg-zinc-50"
>
<Plus size={14} />
New
</button>
</div>
{loading ? (
<p className="px-4 py-6 text-sm text-zinc-500">Loading forms...</p>
) : sortedForms.length === 0 ? (
<p className="px-4 py-6 text-sm text-zinc-500">No forms yet. Create your first form.</p>
) : (
<ul className="max-h-[70vh] overflow-auto p-2">
{sortedForms.map(form => (
<li key={form.id}>
<button
type="button"
onClick={() => handleSelectForm(form)}
className={`w-full rounded-md px-3 py-2 text-left ${selectedId === form.id ? 'bg-zinc-900 text-white' : 'hover:bg-zinc-100 text-zinc-800'}`}
>
<p className="truncate text-sm font-medium">{form.name || form.id}</p>
<p className={`text-xs ${selectedId === form.id ? 'text-zinc-200' : 'text-zinc-500'}`}>
{form.status} · {form.schema.length} fields
</p>
</button>
</li>
))}
</ul>
)}
</aside>
<section className="rounded-xl border border-zinc-200 bg-white shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-zinc-200 px-4 py-3">
<h1 className="text-sm font-semibold text-zinc-900">Form Builder</h1>
<div className="flex items-center gap-2">
{selectedId ? (
<button
type="button"
onClick={() => void removeSelectedForm()}
disabled={saving}
className="inline-flex items-center gap-1 rounded-md border border-red-200 px-2.5 py-1.5 text-xs font-medium text-red-700 hover:bg-red-50 disabled:opacity-50"
>
<Trash2 size={14} />
Delete
</button>
) : null}
<button
type="button"
onClick={() => void saveForm()}
disabled={saving}
className="inline-flex items-center gap-1 rounded-md bg-zinc-900 px-2.5 py-1.5 text-xs font-medium text-white hover:bg-zinc-800 disabled:opacity-50"
>
<Save size={14} />
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
<div className="space-y-4 p-4">
{error ? <p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p> : null}
{notice ? <p className="rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{notice}</p> : null}
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1 text-xs font-medium text-zinc-600">
Name
<input
value={editor.name}
onChange={event => setEditor(prev => ({ ...prev, name: event.target.value }))}
placeholder="Contact us"
className="w-full rounded-md border border-zinc-200 px-3 py-2 text-sm text-zinc-900"
/>
</label>
<label className="space-y-1 text-xs font-medium text-zinc-600">
Status
<select
value={editor.status}
onChange={event => setEditor(prev => ({ ...prev, status: event.target.value as FormStatus }))}
className="w-full rounded-md border border-zinc-200 px-3 py-2 text-sm text-zinc-900"
>
<option value="draft">Draft</option>
<option value="published">Published</option>
</select>
</label>
</div>
<label className="space-y-1 text-xs font-medium text-zinc-600">
Description
<textarea
value={editor.description}
onChange={event => setEditor(prev => ({ ...prev, description: event.target.value }))}
rows={3}
placeholder="Collect leads for webinar registrations"
className="w-full rounded-md border border-zinc-200 px-3 py-2 text-sm text-zinc-900"
/>
</label>
<div className="space-y-2">
<div className="flex items-center justify-between">
<h2 className="text-xs font-semibold uppercase tracking-wide text-zinc-500">Fields</h2>
<button
type="button"
onClick={addField}
className="inline-flex items-center gap-1 rounded-md border border-zinc-200 px-2 py-1 text-xs font-medium text-zinc-700 hover:bg-zinc-50"
>
<Plus size={13} /> Add field
</button>
</div>
<div className="space-y-3">
{editor.schema.map((field, index) => (
<div key={`${field.name}-${index}`} className="rounded-md border border-zinc-200 p-3">
<div className="mb-2 grid gap-2 md:grid-cols-2">
<label className="space-y-1 text-xs font-medium text-zinc-600">
Field name
<input
value={field.name}
onChange={event => updateField(index, { name: event.target.value })}
placeholder="email"
className="w-full rounded-md border border-zinc-200 px-3 py-2 text-sm text-zinc-900"
/>
</label>
<label className="space-y-1 text-xs font-medium text-zinc-600">
Label
<input
value={field.label ?? ''}
onChange={event => updateField(index, { label: event.target.value })}
placeholder="Email address"
className="w-full rounded-md border border-zinc-200 px-3 py-2 text-sm text-zinc-900"
/>
</label>
</div>
<div className="mb-2 grid gap-2 md:grid-cols-2">
<label className="space-y-1 text-xs font-medium text-zinc-600">
Type
<select
value={field.type}
onChange={event => updateField(index, { type: event.target.value as FieldType })}
className="w-full rounded-md border border-zinc-200 px-3 py-2 text-sm text-zinc-900"
>
<option value="text">Text</option>
<option value="textarea">Textarea</option>
<option value="email">Email</option>
<option value="number">Number</option>
<option value="select">Select</option>
<option value="checkbox">Checkbox</option>
</select>
</label>
<label className="space-y-1 text-xs font-medium text-zinc-600">
Required
<select
value={field.required ? 'yes' : 'no'}
onChange={event => updateField(index, { required: event.target.value === 'yes' })}
className="w-full rounded-md border border-zinc-200 px-3 py-2 text-sm text-zinc-900"
>
<option value="no">No</option>
<option value="yes">Yes</option>
</select>
</label>
</div>
{field.type === 'select' ? (
<label className="space-y-1 text-xs font-medium text-zinc-600">
Options (comma-separated)
<input
value={(field.options ?? []).join(', ')}
onChange={event => updateField(index, {
options: event.target.value.split(',').map(option => option.trim()).filter(Boolean),
})}
placeholder="Starter, Pro, Enterprise"
className="w-full rounded-md border border-zinc-200 px-3 py-2 text-sm text-zinc-900"
/>
</label>
) : null}
<div className="mt-2 text-right">
<button
type="button"
onClick={() => removeField(index)}
className="inline-flex items-center gap-1 rounded-md border border-red-200 px-2 py-1 text-xs font-medium text-red-700 hover:bg-red-50"
>
<Trash2 size={13} /> Remove field
</button>
</div>
</div>
))}
</div>
</div>
{selected ? (
<p className="text-xs text-zinc-500">
Created: {formatDate(selected.createdAt)} · Updated: {formatDate(selected.updatedAt)}
</p>
) : null}
{selected ? (
<div className="space-y-2 rounded-md border border-zinc-200 p-3">
<div className="flex items-center justify-between gap-2">
<h2 className="text-xs font-semibold uppercase tracking-wide text-zinc-500">Submissions</h2>
<button
type="button"
onClick={exportSubmissions}
className="inline-flex items-center gap-1 rounded-md border border-zinc-200 px-2 py-1 text-xs font-medium text-zinc-700 hover:bg-zinc-50"
>
Export CSV
</button>
</div>
{submissionsLoading ? (
<p className="text-xs text-zinc-500">Loading submissions...</p>
) : submissions.length === 0 ? (
<p className="text-xs text-zinc-500">No submissions yet.</p>
) : (
<div className="overflow-auto">
<table className="min-w-full border-collapse text-xs">
<thead>
<tr className="border-b border-zinc-200 text-left text-zinc-500">
<th className="px-2 py-1">Submitted</th>
<th className="px-2 py-1">Submission ID</th>
<th className="px-2 py-1">Values</th>
</tr>
</thead>
<tbody>
{submissions.map(item => (
<tr key={item.id} className="border-b border-zinc-100 align-top text-zinc-700">
<td className="px-2 py-1">{formatDate(item.submittedAt)}</td>
<td className="px-2 py-1">{item.id}</td>
<td className="px-2 py-1">
{Object.keys(item.values).length === 0
? '-'
: Object.entries(item.values).map(([key, value]) => `${key}: ${String(value)}`).join(' | ')}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
) : null}
</div>
</section>
</div>
</main>
)
}

View File

@ -0,0 +1,277 @@
import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router'
import { ArrowRight, BookOpen, GraduationCap, Layers3, LogOut, Menu, Shield, Sparkles, X } from 'lucide-react'
import { currentUser, logout } from '../auth/keycloak'
import { getHomeNavItems, trackHomeNavEvent, type HomeNavItem } from '../home-nav'
import { fetchSitemapMenus, toHomeNavItems } from '../sitemap-menu'
type NavLinkItem = Pick<HomeNavItem, 'id' | 'label' | 'to'>
const FEATURE_COPY: Record<string, { eyebrow: string; summary: string; icon: typeof Layers3 }> = {
builder: {
eyebrow: 'Content Studio',
summary: 'Design and publish page experiences with the CMS builder.',
icon: Layers3,
},
scorm: {
eyebrow: 'Learning Authoring',
summary: 'Build SCORM packages and manage interactive course structure.',
icon: GraduationCap,
},
forms: {
eyebrow: 'Lead Capture',
summary: 'Create structured forms and operational workflows.',
icon: Sparkles,
},
blog: {
eyebrow: 'Publishing',
summary: 'Review public editorial surfaces and SEO-driven content.',
icon: BookOpen,
},
'live-courses': {
eyebrow: 'Catalog',
summary: 'Browse live course landing pages and published learning offers.',
icon: GraduationCap,
},
learn: {
eyebrow: 'Learner Portal',
summary: 'Resume learning, check sessions, and manage learner preferences.',
icon: BookOpen,
},
admin: {
eyebrow: 'Operations',
summary: 'Open dashboards for settings, audit, assets, and learner management.',
icon: Shield,
},
sitemap: {
eyebrow: 'Navigation',
summary: 'Control menu structure and sitemap-backed navigation links.',
icon: Sparkles,
},
themes: {
eyebrow: 'Identity',
summary: 'Edit Keycloak sign-in theming and brand presentation.',
icon: Sparkles,
},
'platform-admin': {
eyebrow: 'Tenancy',
summary: 'Manage tenant workspaces and platform-level governance.',
icon: Shield,
},
}
function sameNavItems(left: NavLinkItem[], right: NavLinkItem[]): boolean {
if (left.length !== right.length) return false
return left.every((item, index) => item.id === right[index]?.id && item.label === right[index]?.label && item.to === right[index]?.to)
}
export function HomePage() {
const user = currentUser()
const roleKey = useMemo(
() => (user.roles ?? []).map(role => role.trim().toLowerCase()).sort().join('|'),
[user.roles],
)
const fallbackNavItems = useMemo(() => getHomeNavItems(user.roles ?? []), [roleKey])
const [navItems, setNavItems] = useState<NavLinkItem[]>(fallbackNavItems)
const [mobileNavOpen, setMobileNavOpen] = useState(false)
useEffect(() => {
document.title = 'CMS Platform Home'
}, [])
useEffect(() => {
setNavItems(prev => (sameNavItems(prev, fallbackNavItems) ? prev : fallbackNavItems))
}, [fallbackNavItems])
useEffect(() => {
const roles = user.roles ?? []
const isAdmin = roles.some(role => role.trim().toLowerCase() === 'admin')
if (!isAdmin) {
return
}
let mounted = true
const loadConfiguredNav = async () => {
try {
const menus = await fetchSitemapMenus()
const primary = menus[0]
if (!mounted || !primary || primary.items.length === 0) {
return
}
const configured = toHomeNavItems(primary.items).map(item => ({
id: item.id,
label: item.label,
to: item.to,
}))
if (configured.length > 0) {
setNavItems(prev => (sameNavItems(prev, configured) ? prev : configured))
}
} catch {
// Keep fallback nav when configured sitemap menus are not available.
}
}
void loadConfiguredNav()
return () => {
mounted = false
}
}, [roleKey])
const handleLogout = async () => {
trackHomeNavEvent({ action: 'logout', navId: 'logout' })
await logout()
}
return (
<main data-testid="home-page" className="min-h-screen bg-[radial-gradient(circle_at_top,_#f4efe4,_#f8f6f1_42%,_#ece7dc)] text-stone-900">
<section className="mx-auto flex min-h-screen max-w-7xl flex-col px-4 py-6 sm:px-6 lg:px-8">
<header className="rounded-[28px] border border-stone-200/80 bg-white/85 px-4 py-4 shadow-[0_18px_50px_rgba(65,48,31,0.08)] backdrop-blur md:px-6">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-stone-900 text-white shadow-lg shadow-stone-900/15">
<Layers3 size={18} />
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-stone-500">CMS Platform</p>
<h1 className="text-lg font-semibold text-stone-900">Workspace Home</h1>
</div>
</div>
<div className="hidden items-center gap-2 md:flex">
{navItems.map(item => (
<Link
key={item.id}
to={item.to}
onClick={() => trackHomeNavEvent({ action: 'navigate', navId: item.id, to: item.to })}
className="rounded-full px-3 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900"
>
{item.label}
</Link>
))}
<button
type="button"
data-testid="home-page-logout"
onClick={() => void handleLogout()}
className="inline-flex items-center gap-2 rounded-full border border-stone-200 px-4 py-2 text-sm font-medium text-stone-700 transition hover:border-stone-300 hover:bg-stone-100"
>
<LogOut size={14} />Logout
</button>
</div>
<button
type="button"
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-stone-200 text-stone-700 md:hidden"
aria-label="Toggle home navigation"
onClick={() => setMobileNavOpen(open => !open)}
>
{mobileNavOpen ? <X size={16} /> : <Menu size={16} />}
</button>
</div>
{mobileNavOpen ? (
<div className="mt-4 grid gap-2 border-t border-stone-200 pt-4 md:hidden">
{navItems.map(item => (
<Link
key={item.id}
to={item.to}
onClick={() => {
trackHomeNavEvent({ action: 'navigate', navId: item.id, to: item.to })
setMobileNavOpen(false)
}}
className="rounded-2xl bg-stone-100 px-4 py-3 text-sm font-medium text-stone-800"
>
{item.label}
</Link>
))}
<button
type="button"
onClick={() => void handleLogout()}
className="inline-flex items-center justify-center gap-2 rounded-2xl border border-stone-200 px-4 py-3 text-sm font-medium text-stone-700"
>
<LogOut size={14} />Logout {user.username ? `(${user.username})` : ''}
</button>
</div>
) : null}
</header>
<div className="grid flex-1 gap-6 py-8 lg:grid-cols-[1.2fr_0.8fr] lg:py-10">
<section className="rounded-[32px] border border-stone-200/80 bg-white/88 p-6 shadow-[0_22px_70px_rgba(49,35,18,0.08)] backdrop-blur md:p-8">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-amber-700">Workspace</p>
<h2 className="mt-3 max-w-3xl text-4xl font-semibold tracking-tight text-stone-900 md:text-5xl">
Start from the platform shell, then move into authoring, operations, or learner workflows.
</h2>
<p className="mt-4 max-w-2xl text-sm leading-6 text-stone-600 md:text-base">
Choose the workspace that matches what you want to do: publish content, manage learning, or review platform operations.
</p>
<div className="mt-8 grid gap-4 md:grid-cols-2">
{navItems.map(item => {
const copy = FEATURE_COPY[item.id] ?? {
eyebrow: 'Workspace',
summary: `Open ${item.label}.`,
icon: Layers3,
}
const Icon = copy.icon
return (
<Link
key={item.id}
to={item.to}
onClick={() => trackHomeNavEvent({ action: 'navigate', navId: item.id, to: item.to })}
className="group rounded-[24px] border border-stone-200 bg-stone-50/80 p-5 transition hover:-translate-y-0.5 hover:border-stone-300 hover:bg-white hover:shadow-[0_16px_40px_rgba(66,47,26,0.08)]"
>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-stone-500">{copy.eyebrow}</p>
<h3 className="mt-2 text-lg font-semibold text-stone-900">{item.label}</h3>
<p className="mt-2 text-sm leading-6 text-stone-600">{copy.summary}</p>
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-white text-stone-700 shadow-sm">
<Icon size={18} />
</div>
</div>
<div className="mt-4 inline-flex items-center gap-2 text-sm font-medium text-stone-900">
Open {item.label} <ArrowRight size={14} className="transition group-hover:translate-x-0.5" />
</div>
</Link>
)
})}
</div>
</section>
<aside className="grid gap-6">
<section className="rounded-[28px] border border-stone-200/80 bg-stone-900 p-6 text-stone-50 shadow-[0_22px_70px_rgba(49,35,18,0.14)]">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-amber-300">Current Session</p>
<dl className="mt-5 space-y-4 text-sm">
<div>
<dt className="text-stone-400">Signed in as</dt>
<dd className="mt-1 text-lg font-semibold text-white">{user.username || user.email || 'Authenticated user'}</dd>
</div>
<div>
<dt className="text-stone-400">Roles</dt>
<dd className="mt-2 flex flex-wrap gap-2">
{(user.roles ?? []).map(role => (
<span key={role} className="rounded-full bg-white/10 px-3 py-1 text-xs font-medium text-stone-100">
{role}
</span>
))}
</dd>
</div>
</dl>
</section>
<section className="rounded-[28px] border border-stone-200/80 bg-white/88 p-6 shadow-[0_18px_50px_rgba(65,48,31,0.08)]">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-stone-500">Workspace guide</p>
<ul className="mt-4 space-y-3 text-sm leading-6 text-stone-600">
<li>Use authoring tools for pages, forms, SCORM content, and identity themes.</li>
<li>Use Admin for learners, assets, settings, audit history, and operational tasks.</li>
<li>Use Learner to preview the experience available to enrolled users.</li>
</ul>
</section>
</aside>
</div>
</section>
</main>
)
}

View File

@ -0,0 +1,461 @@
import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router'
import { Check, Eye, FileCode2, GraduationCap, Loader2, ShieldCheck, Sparkles } from 'lucide-react'
import { ApiError, apiRequest } from '../api/client'
import { currentUser } from '../auth/keycloak'
import {
DEFAULT_THEME_FORM_SCHEMA,
DEFAULT_THEME_FORM_VALUES,
type ThemeFieldSchema,
type ThemeSectionSchema,
validateThemeFormValues,
} from '../keycloak-theme-form'
import { buildThemePreviewModel } from '../keycloak-theme-preview'
type ThemeRecord = {
id: string
name: string
slug: string
status: 'draft' | 'published' | 'archived'
scope: 'platform' | 'organization'
type: 'login' | 'account' | 'admin' | 'email'
version: string
metadata?: Record<string, unknown>
}
type ThemeSchemaPayload = {
sections?: ThemeSectionSchema[]
}
function renderField(
field: ThemeFieldSchema,
value: unknown,
onChange: (next: unknown) => void,
error?: string,
) {
const commonClass = `w-full rounded-md border px-3 py-2 text-sm outline-none transition-colors ${error ? 'border-red-300 bg-red-50' : 'border-stone-200 bg-white focus:border-indigo-400'}`
if (field.type === 'textarea') {
return (
<textarea
data-testid={`theme-editor-field-${field.name}`}
className={commonClass}
value={String(value ?? '')}
onChange={e => onChange(e.target.value)}
rows={3}
/>
)
}
if (field.type === 'select') {
return (
<select
data-testid={`theme-editor-field-${field.name}`}
className={commonClass}
value={String(value ?? '')}
onChange={e => onChange(e.target.value)}
>
<option value="">Select...</option>
{(field.options ?? []).map(option => (
<option key={option} value={option}>{option}</option>
))}
</select>
)
}
const inputType = field.type === 'number' ? 'number' : 'text'
return (
<input
data-testid={`theme-editor-field-${field.name}`}
className={commonClass}
value={String(value ?? '')}
type={inputType}
onChange={e => onChange(field.type === 'number' ? Number(e.target.value) : e.target.value)}
/>
)
}
export function KeycloakThemeEditorPage() {
const [sections, setSections] = useState<ThemeSectionSchema[]>(DEFAULT_THEME_FORM_SCHEMA)
const [values, setValues] = useState<Record<string, unknown>>({ ...DEFAULT_THEME_FORM_VALUES })
const [errors, setErrors] = useState<Record<string, string>>({})
const [name, setName] = useState('Default login theme')
const [slug, setSlug] = useState('default-login-theme')
const [version, setVersion] = useState('1.0.0')
const [scope, setScope] = useState<'platform' | 'organization'>('platform')
const [themeType, setThemeType] = useState<'login' | 'account' | 'admin' | 'email'>('login')
const [themeId, setThemeId] = useState<string | null>(null)
const [preview, setPreview] = useState(true)
const [loading, setLoading] = useState(false)
const [exporting, setExporting] = useState(false)
const [deploying, setDeploying] = useState(false)
const [statusText, setStatusText] = useState('')
const [statusKind, setStatusKind] = useState<'idle' | 'success' | 'error'>('idle')
useEffect(() => {
let mounted = true
const bootstrap = async () => {
try {
const [schema, themes] = await Promise.all([
apiRequest<ThemeSchemaPayload>('/api/keycloak-themes/schema'),
apiRequest<ThemeRecord[]>('/api/keycloak-themes'),
])
if (!mounted) {
return
}
if (Array.isArray(schema.sections) && schema.sections.length > 0) {
setSections(schema.sections)
}
if (Array.isArray(themes) && themes.length > 0) {
const first = themes[0]
setThemeId(first.id)
setName(first.name)
setSlug(first.slug)
setVersion(first.version)
setScope(first.scope)
setThemeType(first.type)
setValues({ ...DEFAULT_THEME_FORM_VALUES, ...(first.metadata ?? {}) })
}
} catch (error) {
const message = error instanceof ApiError ? error.message : 'Failed to load theme editor data'
setStatusText(message)
setStatusKind('error')
}
}
void bootstrap()
return () => {
mounted = false
}
}, [])
const flattenedFields = useMemo(() => sections.flatMap(section => section.fields), [sections])
const previewModel = useMemo(() => buildThemePreviewModel(values, themeType), [values, themeType])
const setFieldValue = (name: string, value: unknown) => {
setValues(prev => ({ ...prev, [name]: value }))
}
const persist = async (nextStatus: 'draft' | 'published') => {
const validation = validateThemeFormValues(sections, values)
setErrors(validation.errors)
if (!validation.valid) {
setStatusText('Fix validation errors before saving.')
setStatusKind('error')
return
}
const actor = currentUser().username || currentUser().email || 'theme-editor'
const payload = {
name,
slug,
version,
scope,
type: themeType,
status: nextStatus,
metadata: {
...values,
updatedBy: actor,
},
target: {
realm: 'cms',
...(scope === 'organization' ? { organizationId: 'default-org' } : {}),
},
}
try {
setLoading(true)
if (!themeId) {
const created = await apiRequest<ThemeRecord>('/api/keycloak-themes', {
method: 'POST',
body: JSON.stringify(payload),
})
setThemeId(created.id)
} else {
await apiRequest<ThemeRecord>(`/api/keycloak-themes/${themeId}`, {
method: 'PATCH',
body: JSON.stringify(payload),
})
}
setStatusText(nextStatus === 'published' ? 'Theme published successfully.' : 'Draft saved successfully.')
setStatusKind('success')
} catch (error) {
const message = error instanceof ApiError ? error.message : 'Failed to save theme'
setStatusText(message)
setStatusKind('error')
} finally {
setLoading(false)
}
}
const requestExport = async () => {
if (!themeId) {
setStatusText('Save a draft before requesting export.')
setStatusKind('error')
return
}
const validation = validateThemeFormValues(sections, values)
setErrors(validation.errors)
if (!validation.valid) {
setStatusText('Fix validation errors before requesting export.')
setStatusKind('error')
return
}
try {
setExporting(true)
const exported = await apiRequest<{ checksum?: string }>(`/api/keycloak-themes/${themeId}/export`, {
method: 'POST',
body: JSON.stringify({ metadata: values }),
})
setStatusText(`Theme export created${exported?.checksum ? ` (checksum ${exported.checksum.slice(0, 12)}...)` : ''}.`)
setStatusKind('success')
} catch (error) {
const message = error instanceof ApiError ? error.message : 'Failed to export theme'
setStatusText(message)
setStatusKind('error')
} finally {
setExporting(false)
}
}
const requestDeploy = async () => {
if (!themeId) {
setStatusText('Save and publish a theme before deploying.')
setStatusKind('error')
return
}
const summary = window.prompt('Deployment summary (required):', 'Apply theme to cms realm')
if (!summary || summary.trim() === '') {
setStatusText('Deployment summary is required.')
setStatusKind('error')
return
}
const confirmation = window.prompt('Type DEPLOY to confirm this deployment:', '')
if ((confirmation ?? '').trim().toUpperCase() !== 'DEPLOY') {
setStatusText('Deployment cancelled: confirmation must be DEPLOY.')
setStatusKind('error')
return
}
try {
setDeploying(true)
const deployed = await apiRequest<{ rollbackThemeId?: string; rollbackVersion?: string }>(`/api/keycloak-themes/${themeId}/deploy`, {
method: 'POST',
body: JSON.stringify({
summary: summary.trim(),
confirmation: 'DEPLOY',
}),
})
if (deployed.rollbackThemeId) {
setStatusText(`Theme deployed successfully. Rollback target: ${deployed.rollbackThemeId}${deployed.rollbackVersion ? ` (${deployed.rollbackVersion})` : ''}.`)
} else {
setStatusText('Theme deployed successfully.')
}
setStatusKind('success')
} catch (error) {
const message = error instanceof ApiError ? error.message : 'Failed to deploy theme'
setStatusText(message)
setStatusKind('error')
} finally {
setDeploying(false)
}
}
return (
<div data-testid="keycloak-theme-editor-page" className="flex min-h-screen w-full flex-col bg-[linear-gradient(180deg,#f8f5ef_0%,#f3efe8_100%)] lg:h-screen lg:overflow-hidden">
<header className="flex flex-shrink-0 flex-col gap-3 border-b border-stone-200 bg-white px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5 lg:min-h-14 lg:py-2">
<div className="flex min-w-0 flex-wrap items-center gap-3">
<div className="flex w-full items-center gap-2 overflow-x-auto pb-1 sm:w-auto sm:overflow-visible sm:pb-0">
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-indigo-600">
<Sparkles size={13} color="#fff" />
</div>
<span className="text-sm font-semibold text-stone-900">Keycloak Theme Editor</span>
</div>
<div className="h-4 w-px bg-stone-200" />
<div className="flex max-w-full items-center gap-0.5 overflow-x-auto rounded-md bg-stone-100 p-0.5">
<Link to="/admin/builder" className="flex items-center gap-1 rounded px-2.5 py-1 text-xs font-medium text-stone-500 transition-colors hover:text-stone-700">
<FileCode2 size={11} />Builder
</Link>
<Link to="/scorm" className="flex items-center gap-1 rounded px-2.5 py-1 text-xs font-medium text-stone-500 transition-colors hover:text-stone-700">
<GraduationCap size={11} />SCORM
</Link>
<Link to="/admin" className="flex items-center gap-1 rounded px-2.5 py-1 text-xs font-medium text-stone-500 transition-colors hover:text-stone-700">
<ShieldCheck size={11} />Admin
</Link>
<span className="flex items-center gap-1 rounded bg-white px-2.5 py-1 text-xs font-medium text-stone-900 shadow-sm">
<Sparkles size={11} />Themes
</span>
</div>
</div>
<div className="flex items-center gap-2">
<button
data-testid="theme-editor-toggle-preview"
onClick={() => setPreview(p => !p)}
className="rounded-md border border-stone-200 bg-white px-3 py-1.5 text-xs font-medium text-stone-600 hover:bg-stone-50"
>
<span className="inline-flex items-center gap-1"><Eye size={12} />{preview ? 'Hide preview' : 'Show preview'}</span>
</button>
<button
data-testid="theme-editor-save-draft"
onClick={() => void persist('draft')}
disabled={loading}
className="rounded-md border border-indigo-200 bg-indigo-50 px-3 py-1.5 text-xs font-medium text-indigo-700 hover:bg-indigo-100 disabled:opacity-50"
>
{loading ? <span className="inline-flex items-center gap-1"><Loader2 size={12} className="animate-spin" />Saving...</span> : 'Save draft'}
</button>
<button
data-testid="theme-editor-publish"
onClick={() => void persist('published')}
disabled={loading}
className="rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
>
Publish
</button>
<button
data-testid="theme-editor-export"
onClick={() => void requestExport()}
disabled={exporting}
className="rounded-md border border-stone-200 bg-white px-3 py-1.5 text-xs font-medium text-stone-700 hover:bg-stone-50 disabled:opacity-50"
>
{exporting ? <span className="inline-flex items-center gap-1"><Loader2 size={12} className="animate-spin" />Exporting...</span> : 'Export package'}
</button>
<button
data-testid="theme-editor-deploy"
onClick={() => void requestDeploy()}
disabled={deploying}
className="rounded-md border border-amber-200 bg-amber-50 px-3 py-1.5 text-xs font-semibold text-amber-800 hover:bg-amber-100 disabled:opacity-50"
>
{deploying ? <span className="inline-flex items-center gap-1"><Loader2 size={12} className="animate-spin" />Deploying...</span> : 'Deploy'}
</button>
</div>
</header>
<div className="flex min-h-0 flex-1 flex-col overflow-auto lg:flex-row lg:overflow-hidden">
<div className="min-w-0 flex-1 overflow-y-auto p-4 sm:p-6">
<div className="mb-4 grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-5">
<div>
<label className="mb-1 block text-xs font-medium text-stone-600">Theme name</label>
<input data-testid="theme-editor-name" value={name} onChange={e => setName(e.target.value)} className="w-full rounded-md border border-stone-200 px-3 py-2 text-sm" />
</div>
<div>
<label className="mb-1 block text-xs font-medium text-stone-600">Slug</label>
<input data-testid="theme-editor-slug" value={slug} onChange={e => setSlug(e.target.value)} className="w-full rounded-md border border-stone-200 px-3 py-2 text-sm" />
</div>
<div>
<label className="mb-1 block text-xs font-medium text-stone-600">Version</label>
<input data-testid="theme-editor-version" value={version} onChange={e => setVersion(e.target.value)} className="w-full rounded-md border border-stone-200 px-3 py-2 text-sm" />
</div>
<div>
<label className="mb-1 block text-xs font-medium text-stone-600">Scope</label>
<select data-testid="theme-editor-scope" value={scope} onChange={e => setScope(e.target.value as 'platform' | 'organization')} className="w-full rounded-md border border-stone-200 px-3 py-2 text-sm">
<option value="platform">Platform</option>
<option value="organization">Organization</option>
</select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-stone-600">Theme type</label>
<select data-testid="theme-editor-type" value={themeType} onChange={e => setThemeType(e.target.value as 'login' | 'account' | 'admin' | 'email')} className="w-full rounded-md border border-stone-200 px-3 py-2 text-sm">
<option value="login">Login</option>
<option value="account">Account</option>
<option value="admin">Admin</option>
<option value="email">Email</option>
</select>
</div>
</div>
{statusText && (
<div className={`mb-4 rounded-md border px-3 py-2 text-sm ${statusKind === 'error' ? 'border-red-200 bg-red-50 text-red-700' : 'border-emerald-200 bg-emerald-50 text-emerald-700'}`}>
<span className="inline-flex items-center gap-1">{statusKind === 'success' ? <Check size={14} /> : null}{statusText}</span>
</div>
)}
{sections.map(section => (
<section key={section.id} className="mb-5 rounded-xl border border-stone-200 bg-white p-4">
<h2 className="mb-3 text-sm font-semibold text-stone-900">{section.title}</h2>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{section.fields.map(field => (
<div key={field.name} className={field.type === 'textarea' ? 'md:col-span-2' : ''}>
<label className="mb-1 block text-xs font-medium text-stone-600">
{field.label}{field.required ? ' *' : ''}
</label>
{renderField(field, values[field.name], next => setFieldValue(field.name, next), errors[field.name])}
{errors[field.name] ? <p className="mt-1 text-xs text-red-600">{errors[field.name]}</p> : null}
</div>
))}
</div>
</section>
))}
</div>
{preview && (
<aside className="w-full flex-shrink-0 border-t border-stone-200 bg-white p-4 sm:p-5 lg:w-[360px] lg:overflow-y-auto lg:border-l lg:border-t-0">
<h3 className="mb-3 text-sm font-semibold text-stone-900">Draft preview</h3>
<div
data-testid="theme-editor-preview-login"
className="rounded-xl border p-4"
style={{
borderColor: previewModel.login.style.primaryColor,
background: previewModel.login.style.backgroundColor,
color: previewModel.login.style.textColor,
borderRadius: previewModel.login.style.buttonRadius,
fontFamily: previewModel.login.style.fontFamily,
}}
>
<p className="text-xs uppercase tracking-wide opacity-60">Login preview</p>
<h4 className="mt-2 text-lg font-semibold">{previewModel.login.title}</h4>
<p className="mt-2 text-sm opacity-80">{previewModel.login.subtitle}</p>
<button
className="mt-4 rounded-md px-3 py-2 text-sm font-semibold text-white"
style={{ background: previewModel.login.style.primaryColor }}
>
{previewModel.login.buttonLabel}
</button>
<p className="mt-4 text-xs opacity-70">{previewModel.login.style.securityText}</p>
<p className="mt-2 text-xs opacity-70">{previewModel.login.style.footerText}</p>
</div>
<div
data-testid="theme-editor-preview-account"
className="mt-3 rounded-xl border p-4"
style={{
borderColor: previewModel.account.style.primaryColor,
background: previewModel.account.style.backgroundColor,
color: previewModel.account.style.textColor,
borderRadius: previewModel.account.style.buttonRadius,
fontFamily: previewModel.account.style.fontFamily,
}}
>
<p className="text-xs uppercase tracking-wide opacity-60">Account preview</p>
<h4 className="mt-2 text-lg font-semibold">{previewModel.account.title}</h4>
<p className="mt-2 text-sm opacity-80">{previewModel.account.subtitle}</p>
<button
className="mt-4 rounded-md px-3 py-2 text-sm font-semibold text-white"
style={{ background: previewModel.account.style.primaryColor }}
>
{previewModel.account.buttonLabel}
</button>
<p className="mt-4 text-xs opacity-70">{previewModel.account.style.securityText}</p>
<p className="mt-2 text-xs opacity-70">{previewModel.account.style.footerText}</p>
</div>
</aside>
)}
</div>
<footer className="border-t border-stone-200 bg-white px-5 py-2 text-xs text-stone-500">
Schema fields: {flattenedFields.length}. Draft changes are validated before save/publish.
</footer>
</div>
)
}

View File

@ -0,0 +1,175 @@
import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router'
import { Award, Download, ExternalLink } from 'lucide-react'
import { ApiError } from '../api/client'
import { fetchLearnerCertificates, type LearnerCertificateRecord } from '../learner-certificates'
type LoadState = 'loading' | 'ready' | 'error'
export function LearnerCertificatesPage() {
const [records, setRecords] = useState<LearnerCertificateRecord[]>([])
const [selectedID, setSelectedID] = useState<string | null>(null)
const [state, setState] = useState<LoadState>('loading')
const [error, setError] = useState('')
useEffect(() => {
let cancelled = false
const load = async () => {
try {
setState('loading')
const result = await fetchLearnerCertificates()
if (cancelled) return
setRecords(result)
setSelectedID(result[0]?.id ?? null)
setState('ready')
} catch (err) {
if (cancelled) return
const message = err instanceof ApiError ? err.message : 'Failed to load learner certificates'
setError(message)
setState('error')
}
}
void load()
return () => { cancelled = true }
}, [])
const earned = useMemo(() => records.filter(item => item.status === 'earned'), [records])
const pending = useMemo(() => records.filter(item => item.status === 'pending'), [records])
const selected = useMemo(() => records.find(item => item.id === selectedID) ?? null, [records, selectedID])
return (
<div>
<header className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight text-slate-900">Certificates</h1>
<p className="mt-2 text-sm text-slate-600">
Review earned certificates and pending requirements. Certificate download assets are not available yet.
</p>
</header>
{state === 'loading' && <p className="text-sm text-slate-600">Loading certificate records...</p>}
{state === 'error' && <p className="text-sm text-red-600">{error}</p>}
{state === 'ready' && (
<section className="grid gap-5 lg:grid-cols-[1.2fr_1fr]">
<div className="space-y-5">
<article className="rounded-2xl border border-slate-200 bg-slate-50 p-6">
<h2 className="text-lg font-semibold text-slate-900">Earned Certificates</h2>
<p className="mt-1 text-sm text-slate-600">Issued when your enrollment reaches a completed state.</p>
<div className="mt-4 space-y-3">
{earned.length === 0 && (
<p className="rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-600">
No earned certificates yet. Completed records will appear here when available.
</p>
)}
{earned.map(item => (
<button
key={item.id}
type="button"
onClick={() => setSelectedID(item.id)}
className={`w-full rounded-xl border px-4 py-3 text-left transition ${
item.id === selectedID
? 'border-emerald-400 bg-emerald-50'
: 'border-slate-200 bg-white hover:border-slate-300'
}`}
>
<p className="text-sm font-semibold text-slate-900">{item.title}</p>
<p className="mt-1 text-xs text-slate-600">{item.source === 'scorm' ? 'SCORM course' : 'Live course'}</p>
</button>
))}
</div>
</article>
<article className="rounded-2xl border border-slate-200 bg-slate-50 p-6">
<h2 className="text-lg font-semibold text-slate-900">Pending Certificates</h2>
<p className="mt-1 text-sm text-slate-600">Requirements are shown where tracked; otherwise a not-yet-tracked note is shown.</p>
<div className="mt-4 space-y-3">
{pending.length === 0 && (
<p className="rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-600">
No pending certificates right now.
</p>
)}
{pending.map(item => (
<button
key={item.id}
type="button"
onClick={() => setSelectedID(item.id)}
className={`w-full rounded-xl border px-4 py-3 text-left transition ${
item.id === selectedID
? 'border-amber-400 bg-amber-50'
: 'border-slate-200 bg-white hover:border-slate-300'
}`}
>
<p className="text-sm font-semibold text-slate-900">{item.title}</p>
<p className="mt-1 text-xs text-slate-600">{item.requirement}</p>
</button>
))}
</div>
</article>
</div>
<article className="h-fit rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-slate-900">Certificate Detail</h2>
{!selected && (
<p className="mt-3 text-sm text-slate-600">Select a certificate record to view requirement and related learning links.</p>
)}
{selected && (
<div className="mt-4 space-y-4">
<div className="inline-flex items-center gap-2 rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700">
<Award size={14} />
{selected.status === 'earned' ? 'Earned' : 'Pending'}
</div>
<div>
<h3 className="text-base font-semibold text-slate-900">{selected.title}</h3>
<p className="mt-1 text-sm text-slate-600">{selected.requirement}</p>
</div>
{selected.issuedOn && (
<p className="text-xs text-slate-500">Issued: {new Date(selected.issuedOn).toLocaleDateString()}</p>
)}
<div className="flex flex-wrap gap-2">
<Link
to={selected.relatedCoursePath}
className="inline-flex items-center gap-1 rounded-lg border border-sky-300 bg-sky-50 px-3 py-1.5 text-xs font-semibold text-sky-800 hover:border-sky-500 hover:bg-sky-100"
>
<ExternalLink size={14} />
Related course
</Link>
{selected.relatedSessionPath && (
<Link
to={selected.relatedSessionPath}
className="inline-flex items-center gap-1 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 hover:border-slate-400 hover:bg-slate-100"
>
<ExternalLink size={14} />
Related session
</Link>
)}
<button
type="button"
disabled={!selected.downloadAvailable}
className="inline-flex items-center gap-1 rounded-lg border border-slate-300 bg-slate-100 px-3 py-1.5 text-xs font-semibold text-slate-500 disabled:cursor-not-allowed"
title="Certificate file downloads are not available yet."
>
<Download size={14} />
Download (coming soon)
</button>
</div>
</div>
)}
</article>
</section>
)}
</div>
)
}

View File

@ -0,0 +1,125 @@
import { useEffect, useState } from 'react'
import { MessageSquare, Users } from 'lucide-react'
import { ApiError } from '../api/client'
import { fetchLearnerCommunities, joinLearnerCommunity, type LearnerCommunityItem } from '../learner-communities'
type LoadState = 'loading' | 'ready' | 'error'
export function LearnerCommunityPage() {
const [items, setItems] = useState<LearnerCommunityItem[]>([])
const [state, setState] = useState<LoadState>('loading')
const [error, setError] = useState('')
const [joiningID, setJoiningID] = useState('')
useEffect(() => {
let cancelled = false
const load = async () => {
try {
setState('loading')
const result = await fetchLearnerCommunities()
if (cancelled) return
setItems(result)
setState('ready')
} catch (err) {
if (cancelled) return
const message = err instanceof ApiError ? err.message : 'Failed to load communities'
setError(message)
setState('error')
}
}
void load()
return () => { cancelled = true }
}, [])
const handleJoin = async (communityID: string) => {
try {
setJoiningID(communityID)
const membership = await joinLearnerCommunity(communityID)
setItems(prev => prev.map(item => (
item.community.id === communityID
? { ...item, membership }
: item
)))
} catch (err) {
const message = err instanceof ApiError ? err.message : 'Unable to join community'
setError(message)
} finally {
setJoiningID('')
}
}
return (
<div data-testid="learner-community-page">
<header className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight text-slate-900">Community</h1>
<p className="mt-2 text-sm text-slate-600">
Access your learning communities and join channels available to your role or entitlements.
</p>
</header>
{state === 'loading' && <p className="text-sm text-slate-600">Loading your communities...</p>}
{state === 'error' && <p className="text-sm text-red-600">{error}</p>}
{state === 'ready' && (
<section className="space-y-5">
{items.length === 0 && (
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-8 text-sm text-slate-600">
You do not currently have access to any communities.
</div>
)}
{items.map(item => {
const membershipStatus = item.membership?.status?.toLowerCase() ?? ''
const isJoined = membershipStatus === 'active'
return (
<article key={item.community.id} className="rounded-2xl border border-slate-200 bg-slate-50 p-6">
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<span className="rounded-full bg-slate-100 px-2.5 py-1 font-medium text-slate-700">{item.community.visibility || 'private'}</span>
<span className="rounded-full bg-sky-100 px-2.5 py-1 font-medium text-sky-800">{item.community.accessPolicy || 'membership'}</span>
{isJoined && <span className="rounded-full bg-emerald-100 px-2.5 py-1 font-medium text-emerald-800">Joined</span>}
</div>
<h2 className="text-2xl font-semibold tracking-tight text-slate-900">{item.community.name}</h2>
{item.community.description && <p className="mt-2 text-sm text-slate-600">{item.community.description}</p>}
<div className="mt-4 flex items-center gap-2 text-xs font-medium text-slate-600">
<Users size={14} />
Access: {item.access.reason || 'membership'}
</div>
<div className="mt-4">
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.08em] text-slate-500">Channels</p>
<ul className="space-y-2 text-sm text-slate-700">
{item.channels.map(channel => (
<li key={channel.id} className="flex items-center gap-2">
<MessageSquare size={14} className="text-slate-500" />
<span>{channel.name}</span>
{channel.topic && <span className="text-slate-500">- {channel.topic}</span>}
</li>
))}
{item.channels.length === 0 && <li className="text-slate-500">No channels configured yet.</li>}
</ul>
</div>
{!isJoined && (
<div className="mt-5">
<button
type="button"
disabled={joiningID === item.community.id}
onClick={() => { void handleJoin(item.community.id) }}
className="rounded-lg border border-sky-300 bg-sky-50 px-3 py-1.5 text-xs font-semibold text-sky-800 hover:border-sky-500 hover:bg-sky-100 disabled:cursor-not-allowed disabled:opacity-70"
>
{joiningID === item.community.id ? 'Joining...' : 'Join Community'}
</button>
</div>
)}
</article>
)
})}
</section>
)}
</div>
)
}

View File

@ -0,0 +1,124 @@
import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router'
import { ArrowLeft, Lock, PlayCircle } from 'lucide-react'
import { ApiError } from '../api/client'
import { fetchLearnerCourseDetailBySlug, type LearnerCourseDetail } from '../learner-courses'
type LoadState = 'loading' | 'ready' | 'error' | 'not-found'
export function LearnerCourseDetailPage() {
const { slug = '' } = useParams()
const [detail, setDetail] = useState<LearnerCourseDetail | null>(null)
const [state, setState] = useState<LoadState>('loading')
const [error, setError] = useState('')
useEffect(() => {
let cancelled = false
const load = async () => {
try {
setState('loading')
const result = await fetchLearnerCourseDetailBySlug(slug)
if (cancelled) return
if (!result) {
setState('not-found')
return
}
setDetail(result)
setState('ready')
} catch (err) {
if (cancelled) return
setError(err instanceof ApiError ? err.message : 'Failed to load learner course detail')
setState('error')
}
}
void load()
return () => { cancelled = true }
}, [slug])
if (state === 'loading') {
return <p className="text-sm text-slate-600">Loading course details...</p>
}
if (state === 'not-found') {
return (
<div>
<h1 className="text-3xl font-semibold text-slate-900">Course not found</h1>
<p className="mt-2 text-sm text-slate-600">This course may not be available in your learner catalog.</p>
</div>
)
}
if (state === 'error') {
return <p className="text-sm text-red-600">{error}</p>
}
return (
<article data-testid="learner-course-detail-page">
<Link data-testid="learner-course-detail-back" to="/learn/courses" className="mb-6 inline-flex items-center gap-2 text-sm font-semibold text-slate-600 hover:text-slate-900">
<ArrowLeft size={16} />
Back to my courses
</Link>
<header className="rounded-3xl border border-slate-200 bg-slate-50 p-6 md:p-8">
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-slate-500">{detail?.card.statusLabel}</p>
<h1 className="mt-2 text-3xl font-semibold leading-tight tracking-tight text-slate-900 md:text-5xl">{detail?.card.title}</h1>
<p className="mt-3 text-sm text-slate-600">{detail?.card.description || 'Course overview details will expand as resource metadata is added.'}</p>
</header>
{!detail?.card.locked && (
<div className="mt-6">
<Link
data-testid="learner-course-detail-resume"
to="/learn/live"
className="inline-flex items-center gap-2 rounded-lg border border-emerald-300 bg-emerald-50 px-4 py-2 text-sm font-semibold text-emerald-800 hover:border-emerald-500 hover:bg-emerald-100"
>
<PlayCircle size={16} />
Resume Course
</Link>
</div>
)}
{detail?.card.locked && (
<section className="mt-6 rounded-2xl border border-amber-200 bg-amber-50 p-4">
<p className="inline-flex items-center gap-2 text-sm font-semibold text-amber-900">
<Lock size={16} />
Course Locked
</p>
<p className="mt-2 text-sm text-amber-800">{detail.card.lockMessage || 'This course is currently locked.'}</p>
</section>
)}
<section className="mt-6 rounded-2xl border border-slate-200 bg-slate-50 p-6">
<h2 className="text-lg font-semibold text-slate-900">Modules</h2>
<p className="mt-2 text-sm text-slate-600">
Module-level learner content is not yet tracked here. This placeholder will be replaced once module/course APIs are integrated.
</p>
</section>
<section className="mt-4 rounded-2xl border border-slate-200 bg-slate-50 p-6">
<h2 className="text-lg font-semibold text-slate-900">Assets and Resources</h2>
<p className="mt-2 text-sm text-slate-600">
Asset-level course resources are not yet available in this view. Follow-up work will connect protected assets and downloads.
</p>
</section>
<section className="mt-4 rounded-2xl border border-slate-200 bg-slate-50 p-6">
<h2 className="text-lg font-semibold text-slate-900">Cohorts</h2>
{detail && detail.cohorts.length > 0 ? (
<div className="mt-3 space-y-2">
{detail.cohorts.map(cohort => (
<div key={cohort.id} className="rounded-lg border border-slate-200 bg-white p-3">
<p className="text-sm font-semibold text-slate-800">{cohort.title}</p>
{cohort.status && <p className="mt-1 text-xs text-slate-600">Status: {cohort.status}</p>}
</div>
))}
</div>
) : (
<p className="mt-2 text-sm text-slate-600">No learner cohort records are currently available for this course.</p>
)}
</section>
</article>
)
}

View File

@ -0,0 +1,160 @@
import { useMemo } from 'react'
import { Link } from 'react-router'
import { BellRing, BookOpenCheck, CalendarClock, Clock3 } from 'lucide-react'
import { fetchLearnerLiveCourses, type LearnerLiveCourseItem } from '../learner-live-courses'
import { useEffect, useState } from 'react'
import { ApiError } from '../api/client'
type LoadState = 'loading' | 'ready' | 'error'
function flattenUpcomingSessions(items: LearnerLiveCourseItem[]) {
const sessions = items.flatMap(item =>
item.upcomingSessions.map(session => ({
...session,
courseId: item.course.id,
courseTitle: item.course.title,
cohortTitle: item.cohort.title,
})),
)
sessions.sort((a, b) => (a.startsAt ?? 0) - (b.startsAt ?? 0))
return sessions
}
function formatDateTime(value?: number, timezone?: string): string {
if (!value) return 'TBA'
try {
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZone: timezone,
}).format(new Date(value))
} catch {
return new Date(value).toLocaleString()
}
}
export function LearnerDashboardPage() {
const [items, setItems] = useState<LearnerLiveCourseItem[]>([])
const [state, setState] = useState<LoadState>('loading')
const [error, setError] = useState('')
useEffect(() => {
let cancelled = false
const load = async () => {
try {
setState('loading')
const result = await fetchLearnerLiveCourses()
if (cancelled) return
setItems(result)
setState('ready')
} catch (err) {
if (cancelled) return
setError(err instanceof ApiError ? err.message : 'Failed to load learner dashboard')
setState('error')
}
}
void load()
return () => { cancelled = true }
}, [])
const upcoming = useMemo(() => flattenUpcomingSessions(items), [items])
return (
<div data-testid="learner-dashboard-page">
<header className="mb-6">
<p className="inline-flex rounded-full border border-sky-300 bg-sky-100 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-sky-800">
Learner Dashboard
</p>
<h1 className="mt-3 text-3xl font-semibold tracking-tight">Welcome Back</h1>
<p className="mt-2 text-sm text-slate-600">Resume enrolled learning, review your next session, and check operational updates.</p>
</header>
{state === 'error' && <p className="mb-4 text-sm text-red-600">{error}</p>}
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<article className="rounded-xl border border-slate-200 bg-slate-50 p-4">
<p className="text-xs uppercase tracking-[0.1em] text-slate-500">Enrolled Courses</p>
<p className="mt-2 text-2xl font-semibold text-slate-900">{state === 'loading' ? '...' : items.length}</p>
<p className="mt-2 text-xs text-slate-600">Live course enrollments currently available.</p>
</article>
<article className="rounded-xl border border-slate-200 bg-slate-50 p-4">
<p className="text-xs uppercase tracking-[0.1em] text-slate-500">Upcoming Live Sessions</p>
<p className="mt-2 text-2xl font-semibold text-slate-900">{state === 'loading' ? '...' : upcoming.length}</p>
<p className="mt-2 text-xs text-slate-600">Scheduled and live sessions in your enrolled cohorts.</p>
</article>
<article className="rounded-xl border border-stone-200 bg-stone-50 p-4">
<p className="inline-flex items-center gap-1 text-xs uppercase tracking-[0.1em] text-stone-600">
<Clock3 size={14} /> Learning Activity
</p>
<p className="mt-2 text-sm font-semibold text-stone-900">{items.length > 0 ? 'Courses ready to continue' : 'No active courses yet'}</p>
<p className="mt-2 text-xs text-stone-600">Open My Courses to continue an enrolment or review available learning.</p>
</article>
<Link to="/learn/notifications" className="rounded-xl border border-indigo-200 bg-indigo-50 p-4 transition hover:border-indigo-300 hover:bg-indigo-100">
<p className="inline-flex items-center gap-1 text-xs uppercase tracking-[0.1em] text-indigo-700">
<BellRing size={14} /> Notifications
</p>
<p className="mt-2 text-sm font-semibold text-indigo-950">Manage notification preferences</p>
<p className="mt-2 text-xs text-indigo-800">Choose the course, session, product, and billing updates you want to receive.</p>
</Link>
</section>
<section className="mt-6 grid gap-4 lg:grid-cols-2">
<article className="rounded-xl border border-slate-200 p-4">
<h2 className="inline-flex items-center gap-2 text-base font-semibold text-slate-900">
<BookOpenCheck size={18} />
My Courses
</h2>
<p className="mt-2 text-sm text-slate-600">Browse enrolled courses and access states from your learner course list.</p>
<Link
data-testid="learner-dashboard-open-courses"
to="/learn/courses"
className="mt-3 inline-flex rounded-lg border border-sky-300 bg-sky-50 px-3 py-1.5 text-xs font-semibold text-sky-800 hover:bg-sky-100"
>
Open My Courses
</Link>
<Link
data-testid="learner-dashboard-open-scorm"
to="/learn/scorm"
className="ml-2 mt-3 inline-flex rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 hover:border-slate-400 hover:bg-slate-100"
>
View SCORM Courses
</Link>
</article>
<article className="rounded-xl border border-slate-200 p-4">
<h2 className="inline-flex items-center gap-2 text-base font-semibold text-slate-900">
<CalendarClock size={18} />
Next Sessions
</h2>
{upcoming.slice(0, 3).map(session => (
<div key={`${session.courseId}:${session.id}`} className="mt-3 rounded-lg border border-slate-200 bg-slate-50 p-3">
<p className="text-sm font-semibold text-slate-800">{session.title}</p>
<p className="mt-1 text-xs text-slate-600">{session.courseTitle} · {session.cohortTitle}</p>
<p className="mt-1 text-xs text-slate-600">{formatDateTime(session.startsAt, session.timezone)}</p>
</div>
))}
{state !== 'loading' && upcoming.length === 0 && (
<p className="mt-3 text-sm text-slate-600">No upcoming sessions yet. This area will populate as sessions are scheduled.</p>
)}
<Link
data-testid="learner-dashboard-open-live"
to="/learn/live"
className="mt-3 inline-flex rounded-lg border border-sky-300 bg-sky-50 px-3 py-1.5 text-xs font-semibold text-sky-800 hover:bg-sky-100"
>
Open Live Sessions
</Link>
</article>
</section>
</div>
)
}

View File

@ -0,0 +1,91 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router'
import { Lock, PlayCircle } from 'lucide-react'
import { ApiError } from '../api/client'
import { fetchLearnerCourseCards, type LearnerCourseCard } from '../learner-courses'
type LoadState = 'loading' | 'ready' | 'error'
export function LearnerLiveCoursesPage() {
const [items, setItems] = useState<LearnerCourseCard[]>([])
const [state, setState] = useState<LoadState>('loading')
const [error, setError] = useState('')
useEffect(() => {
let cancelled = false
const load = async () => {
try {
setState('loading')
const result = await fetchLearnerCourseCards()
if (cancelled) return
setItems(result)
setState('ready')
} catch (err) {
if (cancelled) return
const message = err instanceof ApiError ? err.message : 'Failed to load learner live courses'
setError(message)
setState('error')
}
}
void load()
return () => { cancelled = true }
}, [])
return (
<div data-testid="learner-live-courses-page">
<header className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight text-slate-900">My Courses</h1>
<p className="mt-2 text-sm text-slate-600">
View enrolled and available courses, then continue with resume/start actions.
</p>
</header>
{state === 'loading' && <p className="text-sm text-slate-600">Loading your live courses...</p>}
{state === 'error' && <p className="text-sm text-red-600">{error}</p>}
{state === 'ready' && (
<section className="space-y-5">
{items.length === 0 && (
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-8 text-sm text-slate-600">
No courses are currently available in your learner catalog.
</div>
)}
{items.map(item => (
<article data-testid={`learner-live-course-card-${item.slug}`} key={item.id} className="rounded-2xl border border-slate-200 bg-slate-50 p-6">
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<span className="rounded-full bg-slate-100 px-2.5 py-1 font-medium text-slate-700">{item.source}</span>
<span className="rounded-full bg-emerald-100 px-2.5 py-1 font-medium text-emerald-800">{item.statusLabel}</span>
</div>
<h2 className="text-2xl font-semibold tracking-tight text-slate-900">{item.title}</h2>
{item.description && <p className="mt-2 text-sm text-slate-600">{item.description}</p>}
<p className="mt-3 text-xs text-slate-500">{item.progressLabel}</p>
{item.locked && (
<div className="mt-4 inline-flex items-center gap-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-medium text-amber-800">
<Lock size={14} />
{item.lockMessage || 'Access is currently restricted for this course.'}
</div>
)}
<div className="mt-5 flex flex-wrap items-center gap-3">
<Link
data-testid={`learner-live-course-open-${item.slug}`}
to={`/learn/courses/${encodeURIComponent(item.slug)}`}
className="inline-flex items-center gap-1 rounded-lg border border-sky-300 bg-sky-50 px-3 py-1.5 text-xs font-semibold text-sky-800 hover:border-sky-500 hover:bg-sky-100"
>
<PlayCircle size={14} />
{item.actionLabel}
</Link>
</div>
</article>
))}
</section>
)}
</div>
)
}

View File

@ -0,0 +1,154 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router'
import { ArrowLeft, CalendarClock, ExternalLink, Lock } from 'lucide-react'
import { ApiError } from '../api/client'
import {
fetchLearnerLiveSession,
joinLearnerLiveSession,
type LearnerLiveSessionDetail,
} from '../learner-live-courses'
type LoadState = 'loading' | 'ready' | 'error' | 'not-found'
function formatDateTime(value?: number, timezone?: string): string {
if (!value) return 'TBA'
try {
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZone: timezone,
}).format(new Date(value))
} catch {
return new Date(value).toLocaleString()
}
}
export function LearnerLiveSessionPage() {
const { id = '', sessionId = '' } = useParams()
const [detail, setDetail] = useState<LearnerLiveSessionDetail | null>(null)
const [state, setState] = useState<LoadState>('loading')
const [error, setError] = useState('')
const [joinError, setJoinError] = useState('')
const [joining, setJoining] = useState(false)
useEffect(() => {
let cancelled = false
const load = async () => {
try {
setState('loading')
const result = await fetchLearnerLiveSession(id, sessionId)
if (cancelled) return
if (!result.session.id || !result.course.id) {
setState('not-found')
return
}
setDetail(result)
setState('ready')
} catch (err) {
if (cancelled) return
if (err instanceof ApiError && err.status === 404) {
setState('not-found')
return
}
setError(err instanceof Error ? err.message : 'Unable to load live session')
setState('error')
}
}
void load()
return () => { cancelled = true }
}, [id, sessionId])
const canJoin = useMemo(() => Boolean(detail?.access.allowed), [detail])
const handleJoin = async () => {
if (!detail) return
setJoinError('')
setJoining(true)
try {
const result = await joinLearnerLiveSession(detail.course.id, detail.session.id)
if (!result.joinUrl) {
throw new Error('Join URL is not available yet')
}
window.open(result.joinUrl, '_blank', 'noopener,noreferrer')
} catch (err) {
setJoinError(err instanceof Error ? err.message : 'Failed to join session')
} finally {
setJoining(false)
}
}
if (state === 'loading') {
return <p className="text-sm text-slate-600">Loading session details...</p>
}
if (state === 'not-found') {
return (
<div>
<h1 className="text-3xl font-semibold text-slate-900">Session not found</h1>
<p className="mt-2 text-sm text-slate-600">This session may not be available for your learner account.</p>
<Link to="/learn/live" className="mt-5 inline-flex items-center gap-2 text-sm font-semibold text-sky-700">
<ArrowLeft size={16} />
Back to live sessions
</Link>
</div>
)
}
if (state === 'error') {
return <p className="text-sm text-red-600">{error}</p>
}
return (
<article data-testid="learner-live-session-detail-page">
<Link data-testid="learner-live-session-back" to="/learn/live" className="mb-6 inline-flex items-center gap-2 text-sm font-semibold text-slate-600 hover:text-slate-900">
<ArrowLeft size={16} />
Learner live sessions
</Link>
<header className="rounded-3xl border border-slate-200 bg-slate-50 p-6 md:p-8">
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-slate-500">{detail?.course.title}</p>
<h1 className="mt-2 text-3xl font-semibold leading-tight tracking-tight text-slate-900 md:text-5xl">{detail?.session.title}</h1>
<p className="mt-4 inline-flex items-center gap-2 text-sm text-slate-600">
<CalendarClock size={15} />
{formatDateTime(detail?.session.startsAt, detail?.session.timezone || detail?.cohort.timezone)}
</p>
</header>
{!canJoin && (
<section className="mt-6 rounded-2xl border border-amber-200 bg-amber-50 p-4">
<p className="inline-flex items-center gap-2 text-sm font-semibold text-amber-900">
<Lock size={16} />
Session Locked
</p>
<p className="mt-2 text-sm text-amber-800">{detail?.access.message || 'Access to this session is currently restricted.'}</p>
</section>
)}
<section className="mt-6 rounded-2xl border border-slate-200 bg-slate-50 p-6">
<p className="text-sm text-slate-600">Cohort: <span className="font-semibold text-slate-800">{detail?.cohort.title}</span></p>
{canJoin ? (
<button
data-testid="learner-live-session-join"
type="button"
onClick={() => { void handleJoin() }}
disabled={joining}
className="mt-4 inline-flex items-center gap-2 rounded-lg border border-emerald-300 bg-emerald-50 px-4 py-2 text-sm font-semibold text-emerald-800 hover:border-emerald-500 hover:bg-emerald-100 disabled:cursor-not-allowed disabled:opacity-60"
>
<ExternalLink size={16} />
{joining ? 'Joining...' : 'Join Session'}
</button>
) : (
<p className="mt-4 text-sm text-slate-500">Join is enabled when enrollment and entitlement checks pass.</p>
)}
{joinError && <p className="mt-3 text-sm text-red-600">{joinError}</p>}
</section>
</article>
)
}

View File

@ -0,0 +1,135 @@
import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router'
import { CalendarClock, Lock } from 'lucide-react'
import { ApiError } from '../api/client'
import { fetchLearnerLiveCourses, type LearnerLiveCourseItem } from '../learner-live-courses'
type LoadState = 'loading' | 'ready' | 'error'
type UpcomingSessionView = {
courseID: string
courseTitle: string
cohortTitle: string
sessionID: string
title: string
startsAt?: number
timezone?: string
access: {
allowed: boolean
message?: string
}
}
function formatDateTime(value?: number, timezone?: string): string {
if (!value) return 'TBA'
try {
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZone: timezone,
}).format(new Date(value))
} catch {
return new Date(value).toLocaleString()
}
}
function toUpcomingSessions(items: LearnerLiveCourseItem[]): UpcomingSessionView[] {
return items
.flatMap(item =>
item.upcomingSessions.map(session => ({
courseID: item.course.id,
courseTitle: item.course.title,
cohortTitle: item.cohort.title,
sessionID: session.id,
title: session.title,
startsAt: session.startsAt,
timezone: session.timezone,
access: session.access,
})),
)
.sort((a, b) => (a.startsAt ?? 0) - (b.startsAt ?? 0))
}
export function LearnerLiveSessionsPage() {
const [items, setItems] = useState<LearnerLiveCourseItem[]>([])
const [state, setState] = useState<LoadState>('loading')
const [error, setError] = useState('')
useEffect(() => {
let cancelled = false
const load = async () => {
try {
setState('loading')
const result = await fetchLearnerLiveCourses()
if (cancelled) return
setItems(result)
setState('ready')
} catch (err) {
if (cancelled) return
setError(err instanceof ApiError ? err.message : 'Failed to load live sessions')
setState('error')
}
}
void load()
return () => { cancelled = true }
}, [])
const sessions = useMemo(() => toUpcomingSessions(items), [items])
return (
<div data-testid="learner-live-sessions-page">
<header className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight text-slate-900">Live Sessions</h1>
<p className="mt-2 text-sm text-slate-600">Upcoming sessions from your enrolled live courses.</p>
</header>
{state === 'loading' && <p className="text-sm text-slate-600">Loading sessions...</p>}
{state === 'error' && <p className="text-sm text-red-600">{error}</p>}
{state === 'ready' && (
<section className="space-y-3">
{sessions.length === 0 && (
<div className="rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600">
No upcoming live sessions are available yet.
</div>
)}
{sessions.map(session => (
<article data-testid={`learner-live-session-card-${session.sessionID}`} key={`${session.courseID}:${session.sessionID}`} className="rounded-xl border border-slate-200 bg-slate-50 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-slate-900">{session.title}</p>
<p className="mt-1 text-xs text-slate-600">{session.courseTitle} · {session.cohortTitle}</p>
<p className="mt-1 inline-flex items-center gap-1 text-xs text-slate-600">
<CalendarClock size={13} />
{formatDateTime(session.startsAt, session.timezone)}
</p>
</div>
<Link
data-testid={`learner-live-session-open-${session.sessionID}`}
to={`/learn/live/${session.courseID}/sessions/${session.sessionID}`}
className="rounded-lg border border-sky-300 bg-sky-50 px-3 py-1.5 text-xs font-semibold text-sky-800 hover:border-sky-500 hover:bg-sky-100"
>
Open Session
</Link>
</div>
{!session.access.allowed && (
<p className="mt-3 inline-flex items-center gap-1 text-xs text-amber-800">
<Lock size={13} />
{session.access.message || 'Session access is currently restricted.'}
</p>
)}
</article>
))}
</section>
)}
</div>
)
}

View File

@ -0,0 +1,128 @@
import { useEffect, useState } from 'react'
import { ApiError } from '../api/client'
import {
fetchLearnerProfile,
readLearnerNotificationPreferences,
saveLearnerNotificationPreferences,
type LearnerNotificationPreferences,
type LearnerProfile,
} from '../learner-preferences'
type LoadState = 'loading' | 'ready' | 'error'
export function LearnerNotificationsPage() {
const [profile, setProfile] = useState<LearnerProfile | null>(null)
const [preferences, setPreferences] = useState<LearnerNotificationPreferences | null>(null)
const [state, setState] = useState<LoadState>('loading')
const [error, setError] = useState('')
const [saveMessage, setSaveMessage] = useState('')
useEffect(() => {
let cancelled = false
const load = async () => {
try {
setState('loading')
const me = await fetchLearnerProfile()
if (cancelled) return
setProfile(me)
setPreferences(readLearnerNotificationPreferences(me.id))
setState('ready')
} catch (err) {
if (cancelled) return
const message = err instanceof ApiError ? err.message : 'Failed to load learner notification preferences'
setError(message)
setState('error')
}
}
void load()
return () => { cancelled = true }
}, [])
const save = async () => {
if (!preferences) return
const result = await saveLearnerNotificationPreferences(preferences, profile?.id)
setSaveMessage(result.target === 'backend'
? 'Notification preferences synced with backend profile settings.'
: 'Notification preferences saved locally in this browser.')
}
return (
<div data-testid="learner-notifications-page">
<header className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight text-slate-900">Notification Preferences</h1>
<p className="mt-2 text-sm text-slate-600">
Choose which learner notifications you receive for courses, live sessions, product updates, and billing.
</p>
</header>
{state === 'loading' && <p className="text-sm text-slate-600">Loading notification preferences...</p>}
{state === 'error' && <p className="text-sm text-red-600">{error}</p>}
{state === 'ready' && preferences && (
<section className="space-y-5">
<article className="rounded-2xl border border-slate-200 bg-slate-50 p-6">
<h2 className="text-lg font-semibold text-slate-900">Delivery Categories</h2>
<p className="mt-2 text-sm text-slate-600">
Preferences are tied to your learner profile when backend support allows updates; otherwise they remain local to this browser.
</p>
<div className="mt-4 space-y-3 text-sm">
<label className="flex items-center justify-between rounded-xl border border-slate-200 bg-white px-3 py-2">
<span>Course announcements</span>
<input
type="checkbox"
checked={preferences.courseAnnouncements}
onChange={event => setPreferences(prev => prev ? { ...prev, courseAnnouncements: event.target.checked } : prev)}
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-slate-200 bg-white px-3 py-2">
<span>Live session reminders</span>
<input
type="checkbox"
checked={preferences.liveSessionReminders}
onChange={event => setPreferences(prev => prev ? { ...prev, liveSessionReminders: event.target.checked } : prev)}
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-slate-200 bg-white px-3 py-2">
<span>Product updates</span>
<input
type="checkbox"
checked={preferences.productUpdates}
onChange={event => setPreferences(prev => prev ? { ...prev, productUpdates: event.target.checked } : prev)}
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-slate-200 bg-white px-3 py-2">
<span>Billing alerts</span>
<input
type="checkbox"
checked={preferences.billingAlerts}
onChange={event => setPreferences(prev => prev ? { ...prev, billingAlerts: event.target.checked } : prev)}
/>
</label>
</div>
<div className="mt-4 flex flex-wrap items-center gap-3">
<button
data-testid="learner-notifications-save-preferences"
type="button"
onClick={() => { void save() }}
className="inline-flex items-center rounded-lg border border-sky-300 bg-sky-50 px-3 py-1.5 text-xs font-semibold text-sky-800 hover:border-sky-500 hover:bg-sky-100"
>
Save preferences
</button>
<p className="text-xs text-slate-500">Signed in as {profile?.username || profile?.email || 'authenticated learner'}.</p>
</div>
{saveMessage && <p className="mt-3 text-xs text-slate-600">{saveMessage}</p>}
</article>
</section>
)}
</div>
)
}

View File

@ -0,0 +1,209 @@
import { useEffect, useMemo, useState } from 'react'
import { ApiError } from '../api/client'
import {
acceptAllConsent,
readConsentPreferences,
rejectNonEssentialConsent,
saveConsentPreferences,
withDecisionTimestamp,
} from '../consent'
import { fetchLearnerProfile, type LearnerProfile } from '../learner-preferences'
type LoadState = 'loading' | 'ready' | 'error'
type EditableConsent = {
analytics: boolean
marketing: boolean
preferences: boolean
}
function keycloakAccountURL(): string {
const explicit = (import.meta.env.VITE_KEYCLOAK_ACCOUNT_URL ?? '').trim()
if (explicit) return explicit
const baseURL = (import.meta.env.VITE_KEYCLOAK_URL ?? 'http://localhost:8081').replace(/\/+$/, '')
const realm = (import.meta.env.VITE_KEYCLOAK_REALM ?? 'cms').trim()
return `${baseURL}/realms/${realm}/account`
}
export function LearnerProfilePage() {
const [state, setState] = useState<LoadState>('loading')
const [error, setError] = useState('')
const [profile, setProfile] = useState<LearnerProfile | null>(null)
const [consentStatus, setConsentStatus] = useState('')
const savedConsent = useMemo(() => readConsentPreferences(), [])
const [consentDraft, setConsentDraft] = useState<EditableConsent>({
analytics: savedConsent?.analytics ?? false,
marketing: savedConsent?.marketing ?? false,
preferences: savedConsent?.preferences ?? false,
})
useEffect(() => {
let cancelled = false
const load = async () => {
try {
setState('loading')
const nextProfile = await fetchLearnerProfile()
if (cancelled) return
setProfile(nextProfile)
setState('ready')
} catch (err) {
if (cancelled) return
const message = err instanceof ApiError ? err.message : 'Failed to load learner profile settings'
setError(message)
setState('error')
}
}
void load()
return () => { cancelled = true }
}, [])
const saveConsent = (next: EditableConsent) => {
saveConsentPreferences(withDecisionTimestamp({
necessary: true,
analytics: next.analytics,
marketing: next.marketing,
preferences: next.preferences,
}))
setConsentStatus('Cookie preferences updated for this browser.')
}
const applyAllConsent = () => {
saveConsentPreferences(acceptAllConsent())
setConsentDraft({ analytics: true, marketing: true, preferences: true })
setConsentStatus('All optional cookie categories enabled for this browser.')
}
const rejectOptionalConsent = () => {
saveConsentPreferences(rejectNonEssentialConsent())
setConsentDraft({ analytics: false, marketing: false, preferences: false })
setConsentStatus('Only necessary cookies remain enabled for this browser.')
}
return (
<div data-testid="learner-profile-page">
<header className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight text-slate-900">Profile & Preferences</h1>
<p className="mt-2 text-sm text-slate-600">
Manage your learner identity view, notification preferences, and cookie consent choices.
</p>
</header>
{state === 'loading' && <p className="text-sm text-slate-600">Loading profile settings...</p>}
{state === 'error' && <p className="text-sm text-red-600">{error}</p>}
{state === 'ready' && profile && (
<section className="space-y-5">
<article className="rounded-2xl border border-slate-200 bg-slate-50 p-6">
<h2 className="text-lg font-semibold text-slate-900">Authenticated Profile</h2>
<dl className="mt-4 grid gap-3 text-sm text-slate-700 md:grid-cols-2">
<div>
<dt className="text-xs uppercase tracking-[0.1em] text-slate-500">Username</dt>
<dd>{profile.username || 'Not provided by identity provider'}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-[0.1em] text-slate-500">Email</dt>
<dd>{profile.email || 'Not provided by identity provider'}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-[0.1em] text-slate-500">Primary Role</dt>
<dd>{profile.role || 'learner'}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-[0.1em] text-slate-500">User ID</dt>
<dd className="font-mono text-xs">{profile.id || 'Not available'}</dd>
</div>
</dl>
</article>
<article className="rounded-2xl border border-slate-200 bg-slate-50 p-6">
<h2 className="text-lg font-semibold text-slate-900">Cookie Preferences</h2>
<p className="mt-2 text-sm text-slate-600">
Reopen and adjust cookie consent choices for this browser.
</p>
<div className="mt-4 grid gap-3 text-sm text-slate-700 md:grid-cols-3">
<label className="flex items-center justify-between rounded-lg border border-slate-200 bg-white px-3 py-2">
Analytics
<input
type="checkbox"
checked={consentDraft.analytics}
onChange={event => saveConsent({
...consentDraft,
analytics: event.target.checked,
})}
/>
</label>
<label className="flex items-center justify-between rounded-lg border border-slate-200 bg-white px-3 py-2">
Marketing
<input
type="checkbox"
checked={consentDraft.marketing}
onChange={event => saveConsent({
...consentDraft,
marketing: event.target.checked,
})}
/>
</label>
<label className="flex items-center justify-between rounded-lg border border-slate-200 bg-white px-3 py-2">
Preferences
<input
type="checkbox"
checked={consentDraft.preferences}
onChange={event => saveConsent({
...consentDraft,
preferences: event.target.checked,
})}
/>
</label>
</div>
<div className="mt-4 flex flex-wrap gap-3">
<button
data-testid="learner-profile-accept-all-cookies"
type="button"
onClick={applyAllConsent}
className="rounded-lg border border-sky-300 bg-sky-50 px-3 py-1.5 text-xs font-semibold text-sky-800 hover:border-sky-500 hover:bg-sky-100"
>
Accept all cookies
</button>
<button
data-testid="learner-profile-reject-optional-cookies"
type="button"
onClick={rejectOptionalConsent}
className="rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 hover:border-slate-400 hover:bg-slate-100"
>
Reject optional cookies
</button>
</div>
</article>
<article className="rounded-2xl border border-slate-200 bg-slate-50 p-6">
<h2 className="text-lg font-semibold text-slate-900">Account & Security</h2>
<p className="mt-2 text-sm text-slate-600">
Password, MFA, and account recovery are managed by Keycloak account settings.
</p>
<div className="mt-4 flex flex-wrap gap-3">
<a
href={keycloakAccountURL()}
target="_blank"
rel="noreferrer"
className="rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 hover:border-slate-400 hover:bg-slate-100"
>
Open account settings
</a>
<p className="inline-flex items-center rounded-lg border border-amber-200 bg-amber-50 px-3 py-1.5 text-xs font-medium text-amber-800">
In-platform password reset and MFA enrollment are not implemented in this portal.
</p>
</div>
</article>
{consentStatus && (
<p className="text-xs font-medium text-emerald-700">{consentStatus}</p>
)}
</section>
)}
</div>
)
}

View File

@ -0,0 +1,124 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router'
import { AlertTriangle, PlayCircle } from 'lucide-react'
import { ApiError } from '../api/client'
import { fetchLearnerScormCourses, type LearnerScormCourseItem } from '../learner-scorm'
type LoadState = 'loading' | 'ready' | 'error'
function launchStatusLabel(item: LearnerScormCourseItem): string {
if (item.launchState.state === 'ready') {
return item.launchState.resumeAvailable ? 'resume ready' : 'launch ready'
}
if (item.launchState.state === 'export_not_ready') return 'export pending'
if (item.launchState.state === 'missing_package') return 'package missing'
return 'locked'
}
export function LearnerScormCoursesPage() {
const [items, setItems] = useState<LearnerScormCourseItem[]>([])
const [state, setState] = useState<LoadState>('loading')
const [error, setError] = useState('')
useEffect(() => {
let cancelled = false
const load = async () => {
try {
setState('loading')
const result = await fetchLearnerScormCourses()
if (cancelled) return
setItems(result)
setState('ready')
} catch (err) {
if (cancelled) return
const message = err instanceof ApiError ? err.message : 'Failed to load learner SCORM courses'
setError(message)
setState('error')
}
}
void load()
return () => { cancelled = true }
}, [])
return (
<div data-testid="learner-scorm-courses-page">
<header className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight text-slate-900">SCORM Courses</h1>
<p className="mt-2 text-sm text-slate-600">
Launch enrolled SCORM packages and see resume placeholders while runtime tracking is being integrated.
</p>
</header>
{state === 'loading' && <p className="text-sm text-slate-600">Loading your SCORM courses...</p>}
{state === 'error' && <p className="text-sm text-red-600">{error}</p>}
{state === 'ready' && (
<section className="space-y-5">
{items.length === 0 && (
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-8 text-sm text-slate-600">
No SCORM enrollments are available for your account yet.
</div>
)}
{items.map(item => (
<article data-testid={`learner-scorm-course-card-${item.course.id}`} key={item.course.id} className="rounded-2xl border border-slate-200 bg-slate-50 p-6">
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<span className="rounded-full bg-slate-100 px-2.5 py-1 font-medium text-slate-700">{item.enrollment.status || 'enrolled'}</span>
<span className="rounded-full bg-sky-100 px-2.5 py-1 font-medium text-sky-800">{launchStatusLabel(item)}</span>
{item.course.subject && (
<span className="rounded-full bg-indigo-100 px-2.5 py-1 font-medium uppercase tracking-wide text-indigo-800">{item.course.subject}</span>
)}
{item.course.gradeLevel && (
<span className="rounded-full bg-violet-100 px-2.5 py-1 font-medium uppercase tracking-wide text-violet-800">{item.course.gradeLevel}</span>
)}
</div>
<h2 className="text-2xl font-semibold tracking-tight text-slate-900">{item.course.title}</h2>
{item.course.description && <p className="mt-2 text-sm text-slate-600">{item.course.description}</p>}
{item.course.moduleTypes.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{item.course.moduleTypes.map(module => (
<span key={module} className="rounded bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-700">
{module.replaceAll('_', ' ')}
</span>
))}
</div>
)}
{item.launchState.message && (
<p className="mt-3 text-xs text-slate-500">{item.launchState.message}</p>
)}
{!item.launchState.allowed && (
<div className="mt-4 inline-flex items-center gap-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-medium text-amber-800">
<AlertTriangle size={14} />
{item.launchState.progressNote || 'Launch is currently unavailable for this enrollment.'}
</div>
)}
<div className="mt-5 flex flex-wrap items-center gap-3">
<Link
data-testid={`learner-scorm-course-view-${item.course.id}`}
to={`/learn/scorm/${encodeURIComponent(item.course.id)}`}
className="inline-flex items-center gap-1 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 hover:border-slate-400 hover:bg-slate-100"
>
View course
</Link>
<Link
data-testid={`learner-scorm-course-launch-${item.course.id}`}
to={`/learn/scorm/${encodeURIComponent(item.course.id)}`}
className="inline-flex items-center gap-1 rounded-lg border border-sky-300 bg-sky-50 px-3 py-1.5 text-xs font-semibold text-sky-800 hover:border-sky-500 hover:bg-sky-100"
>
<PlayCircle size={14} />
{item.launchState.resumeAvailable ? 'Resume' : 'Launch'}
</Link>
</div>
</article>
))}
</section>
)}
</div>
)
}

View File

@ -0,0 +1,161 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router'
import { AlertCircle, ExternalLink, PlayCircle } from 'lucide-react'
import { ApiError } from '../api/client'
import { fetchLearnerScormLaunch, type LearnerScormLaunchDetail } from '../learner-scorm'
type LoadState = 'loading' | 'ready' | 'error'
function launchStateHeadline(state: LearnerScormLaunchDetail['launchState']['state']): string {
if (state === 'missing_package') return 'Package missing'
if (state === 'export_not_ready') return 'Export not ready'
if (state === 'locked') return 'Access restricted'
return 'Ready to launch'
}
export function LearnerScormLaunchPage() {
const params = useParams<{ id: string }>()
const courseID = (params.id ?? '').trim()
const [detail, setDetail] = useState<LearnerScormLaunchDetail | null>(null)
const [state, setState] = useState<LoadState>('loading')
const [error, setError] = useState('')
const [launchStarted, setLaunchStarted] = useState(false)
useEffect(() => {
if (!courseID) {
setState('error')
setError('A SCORM course id is required to launch this page.')
return
}
let cancelled = false
const load = async () => {
try {
setState('loading')
const result = await fetchLearnerScormLaunch(courseID)
if (cancelled) return
setDetail(result)
setState('ready')
} catch (err) {
if (cancelled) return
const message = err instanceof ApiError ? err.message : 'Failed to load learner SCORM launch details'
setError(message)
setState('error')
}
}
void load()
return () => { cancelled = true }
}, [courseID])
const launchState = detail?.launchState
const canLaunch = Boolean(launchState?.allowed && launchState.state === 'ready')
const blockedMessage = launchState?.reason === 'missing_entitlement'
? 'Launch is blocked until your entitlement is granted or reactivated.'
: 'Launch is blocked until this SCORM course is ready.'
const title = useMemo(() => {
if (!detail?.course.title) return 'SCORM Launch'
return detail.course.title
}, [detail?.course.title])
return (
<div data-testid="learner-scorm-launch-page">
<header className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight text-slate-900">{title}</h1>
<p className="mt-2 text-sm text-slate-600">
Launch your SCORM package and use resume placeholders until runtime progress sync is fully integrated.
</p>
</header>
{state === 'loading' && <p className="text-sm text-slate-600">Loading launch details...</p>}
{state === 'error' && <p className="text-sm text-red-600">{error}</p>}
{state === 'ready' && detail && launchState && (
<section className="space-y-5">
<article className="rounded-2xl border border-slate-200 bg-slate-50 p-6">
<div className="mb-4 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<span className="rounded-full bg-slate-100 px-2.5 py-1 font-medium text-slate-700">{detail.enrollment.status || 'enrolled'}</span>
<span className="rounded-full bg-sky-100 px-2.5 py-1 font-medium text-sky-800">{launchState.state}</span>
{detail.course.subject && (
<span className="rounded-full bg-indigo-100 px-2.5 py-1 font-medium uppercase tracking-wide text-indigo-800">{detail.course.subject}</span>
)}
{detail.course.gradeLevel && (
<span className="rounded-full bg-violet-100 px-2.5 py-1 font-medium uppercase tracking-wide text-violet-800">{detail.course.gradeLevel}</span>
)}
</div>
<h2 className="text-xl font-semibold text-slate-900">{launchStateHeadline(launchState.state)}</h2>
<p className="mt-2 text-sm text-slate-600">{launchState.message || 'Launch details are not available for this course yet.'}</p>
{detail.course.moduleTypes.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1.5">
{detail.course.moduleTypes.map(module => (
<span key={module} className="rounded bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-700">
{module.replaceAll('_', ' ')}
</span>
))}
</div>
)}
{launchState.progressNote && (
<div className="mt-4 rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-700">
{launchState.progressNote}
</div>
)}
{!canLaunch && (
<div className="mt-4 inline-flex items-center gap-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-medium text-amber-800">
<AlertCircle size={14} />
{blockedMessage}
</div>
)}
<div className="mt-5 flex flex-wrap items-center gap-3">
<button
data-testid="learner-scorm-launch-primary"
type="button"
disabled={!canLaunch}
onClick={() => setLaunchStarted(true)}
className="inline-flex items-center gap-1 rounded-lg border border-sky-300 bg-sky-50 px-3 py-1.5 text-xs font-semibold text-sky-800 enabled:hover:border-sky-500 enabled:hover:bg-sky-100 disabled:cursor-not-allowed disabled:border-slate-200 disabled:bg-slate-100 disabled:text-slate-500"
>
<PlayCircle size={14} />
{launchState.resumeAvailable ? 'Resume package' : 'Launch package'}
</button>
{launchState.launchUrl && (
<a
data-testid="learner-scorm-open-export-endpoint"
href={launchState.launchUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 hover:border-slate-400 hover:bg-slate-100"
>
<ExternalLink size={14} />
Open export endpoint
</a>
)}
<Link
data-testid="learner-scorm-back-to-courses"
to="/learn/scorm"
className="inline-flex items-center rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 hover:border-slate-400 hover:bg-slate-100"
>
Back to SCORM courses
</Link>
</div>
</article>
{launchStarted && canLaunch && (
<article className="rounded-2xl border border-emerald-200 bg-emerald-50 p-6">
<h3 className="text-lg font-semibold text-emerald-900">SCORM launch started</h3>
<p className="mt-2 text-sm text-emerald-800">
Runtime playback is staged here. Progress and bookmark resume remain placeholder behavior until full SCORM runtime APIs are connected.
</p>
</article>
)}
</section>
)}
</div>
)
}

View File

@ -0,0 +1,159 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router'
import { ArrowLeft, CalendarClock, Users } from 'lucide-react'
import { ApiError } from '../api/client'
import { applyLiveCourseSeo } from '../live-course-seo'
import {
fetchPublicLiveCourseBySlug,
fetchPublicLiveCourseCohortsBySlug,
type LiveCourse,
type LiveCourseCohort,
} from '../live-courses'
type LoadState = 'loading' | 'ready' | 'error' | 'not-found'
function formatDateTime(value?: number, timezone?: string): string {
if (!value) return 'TBA'
try {
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZone: timezone,
}).format(new Date(value))
} catch {
return new Date(value).toLocaleString()
}
}
export function LiveCourseDetailPage() {
const { slug = '' } = useParams()
const [course, setCourse] = useState<LiveCourse | null>(null)
const [cohorts, setCohorts] = useState<LiveCourseCohort[]>([])
const [state, setState] = useState<LoadState>('loading')
const [error, setError] = useState('')
useEffect(() => {
let cancelled = false
const load = async () => {
try {
setState('loading')
const [loadedCourse, loadedCohorts] = await Promise.all([
fetchPublicLiveCourseBySlug(slug),
fetchPublicLiveCourseCohortsBySlug(slug),
])
if (cancelled) return
if (!loadedCourse.slug || !loadedCourse.title) {
setState('not-found')
return
}
setCourse(loadedCourse)
setCohorts(loadedCohorts)
setState('ready')
} catch (err) {
if (cancelled) return
if (err instanceof ApiError && err.status === 404) {
setState('not-found')
return
}
setError(err instanceof Error ? err.message : 'Unable to load live course')
setState('error')
}
}
void load()
return () => { cancelled = true }
}, [slug])
useEffect(() => {
if (!course) return
applyLiveCourseSeo({
title: course.seo?.title || `${course.title} | Live Courses`,
description: course.seo?.description || course.description,
canonicalUrl: course.seo?.canonicalUrl || `${window.location.origin}/live-courses/${course.slug}`,
})
}, [course])
const sortedCohorts = useMemo(() => {
return [...cohorts].sort((a, b) => (a.startsAt ?? 0) - (b.startsAt ?? 0))
}, [cohorts])
if (state === 'loading') {
return <main className="mx-auto max-w-4xl px-4 py-14 text-sm text-slate-600">Loading course details...</main>
}
if (state === 'not-found') {
return (
<main className="mx-auto max-w-4xl px-4 py-14">
<h1 className="text-3xl font-semibold text-slate-900">Live course not found</h1>
<p className="mt-2 text-sm text-slate-600">This course may still be a draft, private, or removed.</p>
<Link to="/live-courses" className="mt-5 inline-flex items-center gap-2 text-sm font-semibold text-cyan-700">
<ArrowLeft size={16} />
Back to live courses
</Link>
</main>
)
}
if (state === 'error') {
return <main className="mx-auto max-w-4xl px-4 py-14 text-sm text-red-600">{error}</main>
}
return (
<main data-testid="live-course-detail-page" className="min-h-screen bg-gradient-to-b from-white via-slate-50 to-cyan-50">
<article className="mx-auto max-w-4xl px-4 py-10 md:py-16">
<Link data-testid="live-course-detail-back" to="/live-courses" className="mb-8 inline-flex items-center gap-2 text-sm font-semibold text-slate-600 hover:text-slate-900">
<ArrowLeft size={16} />
All live courses
</Link>
<header className="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm md:p-8">
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs text-slate-500">
{course?.level && <span className="rounded-full bg-slate-100 px-2.5 py-1 font-medium text-slate-700">{course.level}</span>}
{course?.deliveryMode && <span className="rounded-full bg-cyan-50 px-2.5 py-1 font-medium text-cyan-800">{course.deliveryMode.replace('_', ' ')}</span>}
{course?.durationSummary && <span className="rounded-full bg-emerald-50 px-2.5 py-1 font-medium text-emerald-800">{course.durationSummary}</span>}
</div>
<h1 className="text-3xl font-semibold leading-tight tracking-tight text-slate-900 md:text-5xl">{course?.title}</h1>
<p className="mt-4 text-base leading-relaxed text-slate-600">{course?.description || 'Live course details and cohort availability.'}</p>
</header>
<section className="mt-8 rounded-3xl border border-slate-200 bg-white p-6 shadow-sm md:p-8">
<h2 className="text-xl font-semibold tracking-tight text-slate-900">Open Cohorts</h2>
{sortedCohorts.length === 0 ? (
<p className="mt-3 text-sm text-slate-600">No open cohorts are currently available.</p>
) : (
<div className="mt-4 space-y-3">
{sortedCohorts.map(cohort => (
<div data-testid={`live-course-cohort-${cohort.id}`} key={cohort.id} className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
<p className="text-base font-semibold text-slate-900">{cohort.title}</p>
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-slate-600">
<span className="inline-flex items-center gap-1">
<CalendarClock size={13} />
Starts {formatDateTime(cohort.startsAt, cohort.timezone)}
</span>
<span className="inline-flex items-center gap-1">
<Users size={13} />
{cohort.enrolledCount ?? 0}
{typeof cohort.capacity === 'number' ? ` / ${cohort.capacity}` : ''}
{' enrolled'}
</span>
{cohort.waitlistEnabled && <span className="rounded-full bg-amber-100 px-2 py-1 font-medium text-amber-800">Waitlist enabled</span>}
</div>
</div>
))}
</div>
)}
</section>
</article>
</main>
)
}

View File

@ -0,0 +1,100 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router'
import { ArrowRight, CalendarRange } from 'lucide-react'
import { ApiError } from '../api/client'
import { fetchPublicLiveCourses, type LiveCourse } from '../live-courses'
import { applyLiveCourseSeo } from '../live-course-seo'
type LoadState = 'loading' | 'ready' | 'error'
export function LiveCoursesIndexPage() {
const [courses, setCourses] = useState<LiveCourse[]>([])
const [state, setState] = useState<LoadState>('loading')
const [error, setError] = useState('')
useEffect(() => {
applyLiveCourseSeo({
title: 'Live Courses | CMS Platform',
description: 'Browse current live instructor-led courses and open cohorts.',
canonicalUrl: `${window.location.origin}/live-courses`,
})
}, [])
useEffect(() => {
let cancelled = false
const load = async () => {
try {
setState('loading')
const result = await fetchPublicLiveCourses()
if (cancelled) return
setCourses(result)
setState('ready')
} catch (err) {
if (cancelled) return
const message = err instanceof ApiError ? err.message : 'Failed to load live courses'
setError(message)
setState('error')
}
}
void load()
return () => { cancelled = true }
}, [])
return (
<main data-testid="live-courses-index-page" className="min-h-screen bg-gradient-to-b from-cyan-50 via-white to-slate-50 text-slate-900">
<div className="mx-auto max-w-6xl px-4 py-10 md:px-6 md:py-16">
<header className="mb-10 rounded-3xl border border-cyan-100 bg-white/90 p-6 shadow-sm md:p-8">
<p className="mb-3 inline-flex items-center rounded-full border border-cyan-300 bg-cyan-100 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-cyan-800">
Live Learning
</p>
<h1 className="text-3xl font-semibold tracking-tight md:text-5xl">Published Live Courses</h1>
<p className="mt-3 max-w-3xl text-sm leading-relaxed text-slate-600 md:text-base">
Explore public instructor-led courses and view available cohort details before enrolling.
</p>
</header>
{state === 'loading' && <p className="text-sm text-slate-600">Loading live courses...</p>}
{state === 'error' && <p className="text-sm text-red-600">{error}</p>}
{state === 'ready' && (
<section className="grid gap-4 md:grid-cols-2">
{courses.length === 0 && (
<div className="rounded-2xl border border-slate-200 bg-white p-8 text-sm text-slate-600">
No published live courses are available right now.
</div>
)}
{courses.map(course => (
<article data-testid={`live-course-card-${course.slug}`} key={course.id || course.slug} className="group rounded-2xl border border-slate-200 bg-white p-6 shadow-sm transition hover:-translate-y-0.5 hover:shadow-md">
<div className="mb-4 flex flex-wrap items-center gap-2 text-xs text-slate-500">
{course.level && <span className="rounded-full bg-slate-100 px-2.5 py-1 font-medium text-slate-700">{course.level}</span>}
{course.deliveryMode && <span className="rounded-full bg-cyan-50 px-2.5 py-1 font-medium text-cyan-800">{course.deliveryMode.replace('_', ' ')}</span>}
{course.durationSummary && (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2.5 py-1 font-medium text-emerald-800">
<CalendarRange size={12} />
{course.durationSummary}
</span>
)}
</div>
<h2 className="text-2xl font-semibold tracking-tight text-slate-900">{course.title}</h2>
<p className="mt-3 text-sm leading-relaxed text-slate-600 md:text-base">{course.description || 'View details and open cohorts for this live course.'}</p>
<Link
data-testid={`live-course-open-${course.slug}`}
to={`/live-courses/${course.slug}`}
className="mt-5 inline-flex items-center gap-1 text-sm font-semibold text-cyan-700 transition group-hover:gap-2"
>
View course
<ArrowRight size={16} />
</Link>
</article>
))}
</section>
)}
</div>
</main>
)
}

View File

@ -0,0 +1,133 @@
import { useEffect, useState } from 'react'
import { ApiError, apiRequest } from '../api/client'
import { currentUser } from '../auth/keycloak'
type Tenant = {
id: string
name: string
slug?: string
createdAt?: number
updatedAt?: number
}
function hasPlatformAccess(roles: string[]): boolean {
const normalized = roles.map(role => role.trim().toLowerCase())
return normalized.includes('admin') || normalized.includes('platform_admin') || normalized.includes('platform_owner')
}
export function PlatformAdminPage() {
const user = currentUser()
const roles = user.roles ?? []
const [tenants, setTenants] = useState<Tenant[]>([])
const [name, setName] = useState('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const loadTenants = async () => {
try {
setLoading(true)
setError('')
const next = await apiRequest<Tenant[]>('/api/platform-admin/tenants')
setTenants(Array.isArray(next) ? next : [])
} catch (err) {
const message = err instanceof ApiError ? err.message : 'Failed to load tenants'
setError(message)
} finally {
setLoading(false)
}
}
useEffect(() => {
void loadTenants()
}, [])
const createTenant = async () => {
const trimmedName = name.trim()
if (!trimmedName) return
try {
setSaving(true)
setError('')
await apiRequest('/api/platform-admin/tenants', {
method: 'POST',
body: JSON.stringify({ name: trimmedName }),
})
setName('')
await loadTenants()
} catch (err) {
const message = err instanceof ApiError ? err.message : 'Failed to create tenant'
setError(message)
} finally {
setSaving(false)
}
}
if (!hasPlatformAccess(roles)) {
return (
<main className="mx-auto max-w-4xl p-6" data-testid="platform-admin-denied">
<h1 className="text-xl font-semibold text-zinc-900">Platform admin access required</h1>
<p className="mt-2 text-sm text-zinc-600">This area is restricted to platform admin and owner roles.</p>
</main>
)
}
return (
<main className="mx-auto max-w-5xl space-y-5 p-6" data-testid="platform-admin-page">
<header>
<h1 className="text-xl font-semibold text-zinc-900">Platform Admin</h1>
<p className="mt-1 text-sm text-zinc-600">Manage tenant workspaces and onboarding metadata.</p>
</header>
<section className="rounded-xl border border-zinc-200 bg-white p-4">
<h2 className="text-sm font-semibold text-zinc-900">Create tenant</h2>
<div className="mt-3 flex flex-wrap gap-2">
<input
value={name}
onChange={event => setName(event.target.value)}
placeholder="Tenant name"
className="min-w-60 flex-1 rounded-md border border-zinc-300 px-3 py-2 text-sm"
/>
<button
type="button"
onClick={() => { void createTenant() }}
disabled={saving || name.trim().length === 0}
className="rounded-md bg-zinc-900 px-3 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-60"
>
{saving ? 'Creating…' : 'Create'}
</button>
</div>
</section>
<section className="rounded-xl border border-zinc-200 bg-white p-4">
<h2 className="text-sm font-semibold text-zinc-900">Tenants</h2>
{error ? <p className="mt-2 text-sm text-red-600">{error}</p> : null}
{loading ? <p className="mt-2 text-sm text-zinc-600">Loading tenants…</p> : null}
{!loading && tenants.length === 0 ? <p className="mt-2 text-sm text-zinc-600">No tenants created yet.</p> : null}
{!loading && tenants.length > 0 ? (
<div className="mt-3 overflow-hidden rounded-lg border border-zinc-200">
<table className="min-w-full divide-y divide-zinc-200 text-sm">
<thead className="bg-zinc-50">
<tr>
<th className="px-3 py-2 text-left font-medium text-zinc-700">Name</th>
<th className="px-3 py-2 text-left font-medium text-zinc-700">Slug</th>
<th className="px-3 py-2 text-left font-medium text-zinc-700">ID</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{tenants.map(tenant => (
<tr key={tenant.id}>
<td className="px-3 py-2 text-zinc-900">{tenant.name}</td>
<td className="px-3 py-2 text-zinc-600">{tenant.slug ?? '-'}</td>
<td className="px-3 py-2 font-mono text-xs text-zinc-500">{tenant.id}</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</section>
</main>
)
}

View File

@ -0,0 +1,46 @@
import { useEffect, useState } from 'react'
import { useParams } from 'react-router'
import { ApiError } from '../api/client'
import { PageRenderer } from '../page-renderer'
import { applyPublicPageSeo, fetchPublicPageBySlug } from '../public-pages'
import type { Page } from '../types'
type LoadState = 'loading' | 'ready' | 'not-found' | 'error'
export function PublicCmsPage() {
const { slug = '' } = useParams()
const [page, setPage] = useState<Page | null>(null)
const [state, setState] = useState<LoadState>('loading')
const [message, setMessage] = useState('')
useEffect(() => {
let cancelled = false
void fetchPublicPageBySlug(slug).then(loadedPage => {
if (cancelled) return
setPage(loadedPage)
setState('ready')
}).catch(error => {
if (cancelled) return
if (error instanceof ApiError && error.status === 404) {
setState('not-found')
return
}
setMessage(error instanceof Error ? error.message : 'Unable to load page')
setState('error')
})
return () => { cancelled = true }
}, [slug])
useEffect(() => {
if (page) applyPublicPageSeo(page)
}, [page])
if (state === 'loading') return <main className="min-h-screen bg-white p-8 text-sm text-zinc-600">Loading page...</main>
if (state === 'not-found') return <main className="min-h-screen bg-white p-8"><h1 className="text-2xl font-semibold text-zinc-900">Page not found</h1></main>
if (state === 'error') return <main className="min-h-screen bg-white p-8 text-sm text-red-600">{message}</main>
if (!page) return null
return <PageRenderer blocks={page.blocks} customBlocks={page.customBlocks} theme={page.theme} />
}

View File

@ -0,0 +1,187 @@
import { useEffect, useMemo, useState } from 'react'
import { useParams } from 'react-router'
import { ApiError } from '../api/client'
import { fetchPublicForm, submitPublicForm, type PublicFormField } from '../public-forms'
type LoadState = 'loading' | 'ready' | 'error'
type ValuesState = Record<string, string | boolean>
function defaultValueForField(field: PublicFormField): string | boolean {
if (field.type === 'checkbox') return false
return ''
}
export function PublicFormPage() {
const { id = '' } = useParams()
const [state, setState] = useState<LoadState>('loading')
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [fields, setFields] = useState<PublicFormField[]>([])
const [values, setValues] = useState<ValuesState>({})
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
useEffect(() => {
let cancelled = false
const load = async () => {
try {
setState('loading')
setError('')
const form = await fetchPublicForm(id)
if (cancelled) return
setTitle(form.name)
setDescription(form.description ?? '')
setFields(form.schema)
const nextValues: ValuesState = {}
for (const field of form.schema) {
nextValues[field.name] = defaultValueForField(field)
}
setValues(nextValues)
setState('ready')
} catch (err) {
if (cancelled) return
const message = err instanceof ApiError ? err.message : 'Failed to load form'
setError(message)
setState('error')
}
}
if (id.trim() !== '') {
void load()
} else {
setState('error')
setError('Form id is required')
}
return () => { cancelled = true }
}, [id])
const requiredMissing = useMemo(() => {
return fields
.filter(field => field.required)
.filter(field => {
const value = values[field.name]
if (field.type === 'checkbox') {
return value !== true
}
return String(value ?? '').trim() === ''
})
.map(field => field.name)
}, [fields, values])
const setValue = (name: string, value: string | boolean) => {
setValues(prev => ({ ...prev, [name]: value }))
}
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault()
if (submitting) return
setNotice('')
setError('')
if (requiredMissing.length > 0) {
setError(`Missing required fields: ${requiredMissing.join(', ')}`)
return
}
try {
setSubmitting(true)
const payload: Record<string, unknown> = {}
for (const field of fields) {
payload[field.name] = values[field.name]
}
await submitPublicForm(id, payload)
setNotice('Thanks. Your submission has been received.')
const resetValues: ValuesState = {}
for (const field of fields) {
resetValues[field.name] = defaultValueForField(field)
}
setValues(resetValues)
} catch (err) {
const message = err instanceof ApiError ? err.message : 'Failed to submit form'
setError(message)
} finally {
setSubmitting(false)
}
}
if (state === 'loading') {
return <div className="mx-auto max-w-3xl p-6 text-sm text-slate-600">Loading form...</div>
}
if (state === 'error') {
return <div className="mx-auto max-w-3xl p-6 text-sm text-red-600">{error}</div>
}
return (
<main className="mx-auto max-w-3xl p-6" data-testid="public-form-page">
<header className="mb-5">
<h1 className="text-2xl font-semibold tracking-tight text-slate-900">{title}</h1>
{description && <p className="mt-2 text-sm text-slate-600">{description}</p>}
</header>
<form onSubmit={event => { void handleSubmit(event) }} className="space-y-4 rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
{fields.map(field => (
<label key={field.name} className="block space-y-1 text-sm text-slate-700">
<span className="font-medium">
{field.label || field.name}
{field.required ? ' *' : ''}
</span>
{field.type === 'textarea' ? (
<textarea
value={String(values[field.name] ?? '')}
onChange={event => setValue(field.name, event.target.value)}
rows={4}
className="w-full rounded-md border border-slate-200 px-3 py-2"
/>
) : field.type === 'checkbox' ? (
<input
type="checkbox"
checked={values[field.name] === true}
onChange={event => setValue(field.name, event.target.checked)}
className="h-4 w-4"
/>
) : field.type === 'select' ? (
<select
value={String(values[field.name] ?? '')}
onChange={event => setValue(field.name, event.target.value)}
className="w-full rounded-md border border-slate-200 px-3 py-2"
>
<option value="">Select an option</option>
{(field.options ?? []).map(option => (
<option key={option} value={option}>{option}</option>
))}
</select>
) : (
<input
type={field.type === 'email' ? 'email' : field.type === 'number' ? 'number' : 'text'}
value={String(values[field.name] ?? '')}
onChange={event => setValue(field.name, event.target.value)}
className="w-full rounded-md border border-slate-200 px-3 py-2"
/>
)}
</label>
))}
{error && <p className="text-sm text-red-600">{error}</p>}
{notice && <p className="text-sm text-emerald-700">{notice}</p>}
<button
type="submit"
disabled={submitting}
className="rounded-md bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800 disabled:opacity-60"
>
{submitting ? 'Submitting...' : 'Submit'}
</button>
</form>
</main>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,404 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Plus, Save, Trash2, ArrowUp, ArrowDown, Map } from 'lucide-react'
import {
addMenuItem,
buildPathForSource,
fetchSitemapMenus,
fetchSitemapSources,
flattenParentOptions,
moveMenuItem,
removeMenuItem,
toHomeNavItems,
type SitemapMenu,
type SitemapMenuItem,
type SitemapSourceOption,
type SitemapTargetType,
updateMenuItemLabel,
createSitemapMenu,
updateSitemapMenu,
removeSitemapMenu,
} from '../sitemap-menu'
function newMenuItem(targetType: SitemapTargetType, source: SitemapSourceOption): SitemapMenuItem {
return {
id: `menu-item-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
label: source.label,
targetType,
targetId: source.id,
to: buildPathForSource(targetType, source),
children: [],
}
}
function renderItems(
items: SitemapMenuItem[],
depth: number,
onMove: (id: string, direction: 'up' | 'down') => void,
onRemove: (id: string) => void,
onLabel: (id: string, value: string) => void,
): JSX.Element[] {
const rendered: JSX.Element[] = []
items.forEach(item => {
rendered.push(
<li key={item.id} className="space-y-2 rounded-md border border-zinc-200 bg-zinc-50 p-2" style={{ marginLeft: depth * 16 }}>
<div className="flex flex-wrap items-center gap-2">
<input
value={item.label}
onChange={event => onLabel(item.id, event.target.value)}
className="min-w-[180px] flex-1 rounded-md border border-zinc-200 bg-white px-2 py-1.5 text-sm"
/>
<span className="rounded bg-zinc-200 px-2 py-1 text-xs text-zinc-700">{item.targetType}</span>
<span className="rounded bg-zinc-200 px-2 py-1 text-xs text-zinc-700">{item.to}</span>
<button type="button" onClick={() => onMove(item.id, 'up')} className="rounded border border-zinc-200 bg-white p-1.5 text-zinc-600 hover:bg-zinc-100">
<ArrowUp size={14} />
</button>
<button type="button" onClick={() => onMove(item.id, 'down')} className="rounded border border-zinc-200 bg-white p-1.5 text-zinc-600 hover:bg-zinc-100">
<ArrowDown size={14} />
</button>
<button type="button" onClick={() => onRemove(item.id)} className="rounded border border-red-200 bg-white p-1.5 text-red-700 hover:bg-red-50">
<Trash2 size={14} />
</button>
</div>
</li>,
)
rendered.push(...renderItems(item.children, depth + 1, onMove, onRemove, onLabel))
})
return rendered
}
export function SitemapMenuBuilderPage() {
const [menus, setMenus] = useState<SitemapMenu[]>([])
const [selectedID, setSelectedID] = useState<string | null>(null)
const [name, setName] = useState('Primary Sitemap')
const [description, setDescription] = useState('')
const [items, setItems] = useState<SitemapMenuItem[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [notice, setNotice] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [targetType, setTargetType] = useState<SitemapTargetType>('page')
const [targetID, setTargetID] = useState('')
const [parentID, setParentID] = useState<string>('')
const [sources, setSources] = useState<{ pages: SitemapSourceOption[]; posts: SitemapSourceOption[]; courses: SitemapSourceOption[] }>({
pages: [],
posts: [],
courses: [],
})
const activeTargets = useMemo(() => {
if (targetType === 'post') return sources.posts
if (targetType === 'course') return sources.courses
return sources.pages
}, [sources, targetType])
const parentOptions = useMemo(() => flattenParentOptions(items), [items])
const refresh = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [menusResult, sourcesResult] = await Promise.all([fetchSitemapMenus(), fetchSitemapSources()])
setMenus(menusResult)
setSources(sourcesResult)
if (menusResult.length > 0) {
const selected = menusResult.find(menu => menu.id === selectedID) ?? menusResult[0]
setSelectedID(selected.id)
setName(selected.name)
setDescription(selected.description ?? '')
setItems(selected.items)
} else {
setSelectedID(null)
setName('Primary Sitemap')
setDescription('')
setItems([])
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load sitemap menus')
} finally {
setLoading(false)
}
}, [selectedID])
useEffect(() => {
void refresh()
}, [refresh])
useEffect(() => {
if (!targetID && activeTargets.length > 0) {
setTargetID(activeTargets[0].id)
}
}, [activeTargets, targetID])
const onSelectMenu = (menu: SitemapMenu) => {
setSelectedID(menu.id)
setName(menu.name)
setDescription(menu.description ?? '')
setItems(menu.items)
setError(null)
setNotice(null)
}
const onCreateBlank = () => {
setSelectedID(null)
setName('Primary Sitemap')
setDescription('')
setItems([])
setError(null)
setNotice(null)
}
const onAddItem = () => {
const selectedTarget = activeTargets.find(target => target.id === targetID)
if (!selectedTarget) {
setError('Choose a valid source target before adding')
return
}
const next = addMenuItem(items, parentID || null, newMenuItem(targetType, selectedTarget))
setItems(next)
}
const onSave = async () => {
setSaving(true)
setError(null)
setNotice(null)
const payload = {
name: name.trim(),
description: description.trim(),
items,
}
try {
if (selectedID) {
await updateSitemapMenu(selectedID, payload)
setNotice('Sitemap menu updated')
} else {
const created = await createSitemapMenu(payload)
setSelectedID(created.id)
setNotice('Sitemap menu created')
}
await refresh()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save sitemap menu')
} finally {
setSaving(false)
}
}
const onDelete = async () => {
if (!selectedID) {
return
}
setSaving(true)
setError(null)
setNotice(null)
try {
await removeSitemapMenu(selectedID)
setNotice('Sitemap menu removed')
setSelectedID(null)
await refresh()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete sitemap menu')
} finally {
setSaving(false)
}
}
const previewLinks = useMemo(() => toHomeNavItems(items), [items])
return (
<main className="min-h-screen bg-zinc-100 p-4 md:p-6">
<div className="mx-auto grid max-w-7xl gap-4 lg:grid-cols-[300px_1fr]">
<aside className="rounded-xl border border-zinc-200 bg-white shadow-sm">
<div className="flex items-center justify-between border-b border-zinc-200 px-4 py-3">
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-900">
<Map size={16} />
Sitemap Menus
</div>
<button
type="button"
onClick={onCreateBlank}
className="inline-flex items-center gap-1 rounded-md border border-zinc-200 px-2 py-1 text-xs font-medium text-zinc-700 hover:bg-zinc-50"
>
<Plus size={14} />
New
</button>
</div>
{loading ? (
<p className="px-4 py-6 text-sm text-zinc-500">Loading menus...</p>
) : menus.length === 0 ? (
<p className="px-4 py-6 text-sm text-zinc-500">No sitemap menus yet.</p>
) : (
<ul className="max-h-[70vh] overflow-auto p-2">
{menus.map(menu => (
<li key={menu.id}>
<button
type="button"
onClick={() => onSelectMenu(menu)}
className={`w-full rounded-md px-3 py-2 text-left ${selectedID === menu.id ? 'bg-zinc-900 text-white' : 'hover:bg-zinc-100 text-zinc-800'}`}
>
<p className="truncate text-sm font-medium">{menu.name}</p>
<p className={`text-xs ${selectedID === menu.id ? 'text-zinc-200' : 'text-zinc-500'}`}>
{menu.items.length} top-level items
</p>
</button>
</li>
))}
</ul>
)}
</aside>
<section className="rounded-xl border border-zinc-200 bg-white shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-zinc-200 px-4 py-3">
<h1 className="text-sm font-semibold text-zinc-900">Sitemap Menu Builder</h1>
<div className="flex items-center gap-2">
{selectedID ? (
<button
type="button"
onClick={() => void onDelete()}
disabled={saving}
className="inline-flex items-center gap-1 rounded-md border border-red-200 px-2.5 py-1.5 text-xs font-medium text-red-700 hover:bg-red-50 disabled:opacity-50"
>
<Trash2 size={14} />
Delete
</button>
) : null}
<button
type="button"
onClick={() => void onSave()}
disabled={saving}
className="inline-flex items-center gap-1 rounded-md bg-zinc-900 px-2.5 py-1.5 text-xs font-medium text-white hover:bg-zinc-800 disabled:opacity-50"
>
<Save size={14} />
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
<div className="space-y-4 p-4">
{error ? <p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p> : null}
{notice ? <p className="rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">{notice}</p> : null}
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1 text-xs font-medium text-zinc-600">
Menu name
<input
value={name}
onChange={event => setName(event.target.value)}
className="w-full rounded-md border border-zinc-200 px-3 py-2 text-sm text-zinc-900"
/>
</label>
<label className="space-y-1 text-xs font-medium text-zinc-600">
Description
<input
value={description}
onChange={event => setDescription(event.target.value)}
className="w-full rounded-md border border-zinc-200 px-3 py-2 text-sm text-zinc-900"
/>
</label>
</div>
<div className="rounded-md border border-zinc-200 p-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-zinc-500">Add menu item</p>
<div className="grid gap-2 md:grid-cols-4">
<label className="space-y-1 text-xs font-medium text-zinc-600">
Source type
<select
value={targetType}
onChange={event => {
const nextType = event.target.value as SitemapTargetType
setTargetType(nextType)
setTargetID('')
}}
className="w-full rounded-md border border-zinc-200 px-2 py-1.5 text-sm"
>
<option value="page">Pages</option>
<option value="post">Posts</option>
<option value="course">Courses</option>
</select>
</label>
<label className="space-y-1 text-xs font-medium text-zinc-600">
Target
<select
value={targetID}
onChange={event => setTargetID(event.target.value)}
className="w-full rounded-md border border-zinc-200 px-2 py-1.5 text-sm"
>
{activeTargets.length === 0 ? <option value="">No items</option> : null}
{activeTargets.map(option => (
<option key={option.id} value={option.id}>{option.label}</option>
))}
</select>
</label>
<label className="space-y-1 text-xs font-medium text-zinc-600">
Parent
<select
value={parentID}
onChange={event => setParentID(event.target.value)}
className="w-full rounded-md border border-zinc-200 px-2 py-1.5 text-sm"
>
<option value="">Top level</option>
{parentOptions.map(option => (
<option key={option.id} value={option.id}>{option.label}</option>
))}
</select>
</label>
<div className="flex items-end">
<button
type="button"
onClick={onAddItem}
className="inline-flex h-[34px] w-full items-center justify-center gap-1 rounded-md border border-zinc-200 px-2 py-1 text-xs font-medium text-zinc-700 hover:bg-zinc-50"
>
<Plus size={14} /> Add
</button>
</div>
</div>
</div>
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-zinc-500">Menu tree</p>
{items.length === 0 ? (
<p className="rounded-md border border-zinc-200 p-3 text-sm text-zinc-500">No items yet. Add the first menu entry.</p>
) : (
<ul className="space-y-2">
{renderItems(
items,
0,
(id, direction) => setItems(prev => moveMenuItem(prev, id, direction)),
id => setItems(prev => removeMenuItem(prev, id)),
(id, value) => setItems(prev => updateMenuItemLabel(prev, id, value)),
)}
</ul>
)}
</div>
<div className="rounded-md border border-zinc-200 p-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-zinc-500">Rendered nav preview</p>
{previewLinks.length === 0 ? (
<p className="text-sm text-zinc-500">No preview links yet.</p>
) : (
<ul className="flex flex-wrap gap-2 text-xs">
{previewLinks.map(link => (
<li key={link.id} className="rounded border border-zinc-200 bg-zinc-50 px-2 py-1 text-zinc-700">
{link.label} → {link.to}
</li>
))}
</ul>
)}
</div>
</div>
</section>
</div>
</main>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,132 @@
import { apiRequest, listResource } from './api/client'
export type PublicFormFieldType = 'text' | 'textarea' | 'email' | 'number' | 'select' | 'checkbox'
export type PublicFormField = {
name: string
type: PublicFormFieldType
label?: string
required?: boolean
options?: string[]
}
export type PublicFormSchema = {
id: string
name: string
description?: string
schema: PublicFormField[]
}
export type FormSubmissionRecord = {
id: string
formId: string
submittedAt?: number
values: Record<string, unknown>
}
function asString(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function asNumber(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
}
function normalizeField(raw: unknown): PublicFormField {
const source = (raw ?? {}) as Record<string, unknown>
const type = asString(source.type).toLowerCase()
const normalizedType: PublicFormFieldType = ['text', 'textarea', 'email', 'number', 'select', 'checkbox'].includes(type)
? (type as PublicFormFieldType)
: 'text'
return {
name: asString(source.name),
type: normalizedType,
label: asString(source.label) || undefined,
required: Boolean(source.required),
options: Array.isArray(source.options)
? source.options.map(item => String(item).trim()).filter(Boolean)
: undefined,
}
}
function normalizePublicForm(raw: unknown): PublicFormSchema {
const source = (raw ?? {}) as Record<string, unknown>
const schema = Array.isArray(source.schema) ? source.schema.map(normalizeField) : []
return {
id: asString(source.id),
name: asString(source.name),
description: asString(source.description) || undefined,
schema: schema.filter(field => Boolean(field.name)),
}
}
function normalizeSubmission(raw: unknown): FormSubmissionRecord {
const source = (raw ?? {}) as Record<string, unknown>
const valuesRaw = source.values
const values = typeof valuesRaw === 'object' && valuesRaw !== null && !Array.isArray(valuesRaw)
? (valuesRaw as Record<string, unknown>)
: {}
return {
id: asString(source.id),
formId: asString(source.formId),
submittedAt: asNumber(source.submittedAt),
values,
}
}
export async function fetchPublicForm(formID: string): Promise<PublicFormSchema> {
const result = await apiRequest<unknown>(`/api/public/forms/${encodeURIComponent(formID)}`, { skipAuth: true })
return normalizePublicForm(result)
}
export async function submitPublicForm(formID: string, values: Record<string, unknown>): Promise<{ id: string; formId: string; submittedAt?: number }> {
const result = await apiRequest<unknown>(`/api/public/forms/${encodeURIComponent(formID)}/submissions`, {
method: 'POST',
skipAuth: true,
body: JSON.stringify({ values }),
})
const source = (result ?? {}) as Record<string, unknown>
return {
id: asString(source.id),
formId: asString(source.formId),
submittedAt: asNumber(source.submittedAt),
}
}
export async function fetchAdminFormSubmissions(formID: string): Promise<FormSubmissionRecord[]> {
const result = await listResource<unknown>(`forms/${encodeURIComponent(formID)}/submissions`)
return result.map(normalizeSubmission).filter(item => Boolean(item.id))
}
export function submissionsToCSV(submissions: FormSubmissionRecord[]): string {
const allKeys = new Set<string>()
for (const item of submissions) {
for (const key of Object.keys(item.values)) {
allKeys.add(key)
}
}
const valueKeys = [...allKeys].sort((a, b) => a.localeCompare(b))
const headers = ['id', 'formId', 'submittedAt', ...valueKeys]
const escape = (value: unknown): string => {
const text = typeof value === 'string' ? value : value == null ? '' : JSON.stringify(value)
const normalized = String(text).replace(/\r?\n/g, ' ')
if (normalized.includes(',') || normalized.includes('"')) {
return `"${normalized.replace(/"/g, '""')}"`
}
return normalized
}
const rows = submissions.map(item => {
const rowValues: unknown[] = [item.id, item.formId, item.submittedAt ?? '']
for (const key of valueKeys) {
rowValues.push(item.values[key] ?? '')
}
return rowValues.map(escape).join(',')
})
return [headers.join(','), ...rows].join('\n')
}

View File

@ -0,0 +1,55 @@
import { apiRequest } from './api/client'
import { DEFAULT_THEME } from './cms-utils'
import { normalizePageSEO } from './page-seo'
import type { Page, ThemeConfig } from './types'
export async function fetchPublicPageBySlug(slug: string): Promise<Page> {
const source = await apiRequest<Record<string, unknown>>(`/api/public/pages/${encodeURIComponent(slug)}`, { skipAuth: true })
const now = Date.now()
return {
id: typeof source.id === 'string' ? source.id : '',
name: typeof source.name === 'string' && source.name.trim() ? source.name : 'Untitled page',
slug: typeof source.slug === 'string' ? source.slug : slug,
status: source.status === 'published' ? 'published' : 'draft',
publishedAt: typeof source.publishedAt === 'number' ? source.publishedAt : undefined,
seo: source.seo && typeof source.seo === 'object' ? normalizePageSEO(source.seo) : undefined,
blocks: Array.isArray(source.blocks) ? source.blocks as Page['blocks'] : [],
theme: source.theme && typeof source.theme === 'object' ? source.theme as ThemeConfig : DEFAULT_THEME,
customBlocks: Array.isArray(source.customBlocks) ? source.customBlocks as Page['customBlocks'] : [],
createdAt: typeof source.createdAt === 'number' ? source.createdAt : now,
updatedAt: typeof source.updatedAt === 'number' ? source.updatedAt : now,
}
}
function upsertMeta(attribute: 'name' | 'property', key: string, content?: string) {
const existing = document.head.querySelector(`meta[${attribute}="${key}"]`) as HTMLMetaElement | null
if (!content) {
existing?.remove()
return
}
const element = existing ?? document.createElement('meta')
element.setAttribute(attribute, key)
element.content = content
if (!existing) document.head.appendChild(element)
}
export function applyPublicPageSeo(page: Page): void {
const title = page.seo?.title || page.name
const description = page.seo?.description
document.title = title
upsertMeta('name', 'description', description)
upsertMeta('property', 'og:title', page.seo?.openGraph?.title || title)
upsertMeta('property', 'og:description', page.seo?.openGraph?.description || description)
upsertMeta('property', 'og:image', page.seo?.openGraph?.image)
upsertMeta('property', 'og:type', page.seo?.openGraph?.type || 'website')
const canonicalUrl = page.seo?.canonicalUrl || `${window.location.origin}/pages/${page.slug}`
let canonical = document.head.querySelector('link[rel="canonical"]') as HTMLLinkElement | null
if (!canonical) {
canonical = document.createElement('link')
canonical.rel = 'canonical'
document.head.appendChild(canonical)
}
canonical.href = canonicalUrl
}

View File

@ -0,0 +1,64 @@
import type { RouteObject } from 'react-router'
import { AdminLayout } from '../layouts/AdminLayout'
import { CmsBuilderPage } from '../pages/CmsBuilderPage'
import { ScormPage } from '../pages/ScormPage'
import { AdminPage } from '../pages/AdminPage'
import { AdminDashboardPage } from '../pages/AdminDashboardPage'
import { KeycloakThemeEditorPage } from '../pages/KeycloakThemeEditorPage'
import { FormsBuilderPage } from '../pages/FormsBuilderPage'
import { SitemapMenuBuilderPage } from '../pages/SitemapMenuBuilderPage'
import { FeatureGuardPage } from '../pages/FeatureGuardPage'
import { AdminContentPage } from '../pages/AdminContentPage'
import { AdminLearnersPage } from '../pages/AdminLearnersPage'
import { AdminSettingsPage } from '../pages/AdminSettingsPage'
import { AdminAuditPage } from '../pages/AdminAuditPage'
import { AdminAssetsPage } from '../pages/AdminAssetsPage'
import { AdminBlogPage } from '../pages/AdminBlogPage'
import { AuthoringRunReviewPage } from '../pages/AuthoringRunReviewPage'
function AdminEmbeddedBuilderPage() {
return <CmsBuilderPage embedded />
}
function AdminEmbeddedScormPage() {
return <ScormPage embedded />
}
function AdminNotificationsNotEnabledPage() {
return <FeatureGuardPage title="Notification Operations" summary="Notification operations are not available in this workspace yet." />
}
function AdminBillingNotEnabledPage() {
return <FeatureGuardPage title="Billing Administration" summary="Billing administration is not available in this workspace yet." />
}
function AdminCommunitiesNotEnabledPage() {
return <FeatureGuardPage title="Community Moderation" summary="Community moderation tools are not available in this workspace yet." />
}
export const adminRoutes: RouteObject[] = [
{
path: '/admin',
Component: AdminLayout,
children: [
{ index: true, Component: AdminDashboardPage },
{ path: 'overview-legacy', Component: AdminPage },
{ path: 'builder', Component: AdminEmbeddedBuilderPage, handle: { adminSurface: 'plain' } },
{ path: 'authoring/runs/:runId', Component: AuthoringRunReviewPage },
{ path: 'scorm', Component: AdminEmbeddedScormPage, handle: { adminSurface: 'plain' } },
{ path: 'forms', Component: FormsBuilderPage, handle: { adminSurface: 'plain' } },
{ path: 'sitemap', Component: SitemapMenuBuilderPage, handle: { adminSurface: 'plain' } },
{ path: 'keycloak-themes', Component: KeycloakThemeEditorPage, handle: { adminSurface: 'plain' } },
{ path: 'content', Component: AdminContentPage },
{ path: 'blog', Component: AdminBlogPage, handle: { adminSurface: 'plain' } },
{ path: 'learners', Component: AdminLearnersPage },
{ path: 'notifications', Component: AdminNotificationsNotEnabledPage },
{ path: 'assets', Component: AdminAssetsPage },
{ path: 'billing', Component: AdminBillingNotEnabledPage },
{ path: 'communities', Component: AdminCommunitiesNotEnabledPage },
{ path: 'audit', Component: AdminAuditPage },
{ path: 'settings', Component: AdminSettingsPage },
],
},
]

View File

@ -0,0 +1,17 @@
import { createBrowserRouter, type RouteObject } from 'react-router'
import { adminRoutes } from './admin-routes'
import { learnerRoutes } from './learner-routes'
import { legacyRoutes } from './legacy-routes'
import { publicRoutes } from './public-routes'
export { adminRoutes, learnerRoutes, legacyRoutes, publicRoutes }
export const routeConfig: RouteObject[] = [
...publicRoutes,
...adminRoutes,
...learnerRoutes,
...legacyRoutes,
]
export const router = createBrowserRouter(routeConfig)