mirror: import production source 2/3
This commit is contained in:
parent
1b587d88e6
commit
17e3db9970
92
Frontend/src/app/components/ui/card.tsx
Normal file
92
Frontend/src/app/components/ui/card.tsx
Normal 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,
|
||||
};
|
||||
241
Frontend/src/app/components/ui/carousel.tsx
Normal file
241
Frontend/src/app/components/ui/carousel.tsx
Normal 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,
|
||||
};
|
||||
353
Frontend/src/app/components/ui/chart.tsx
Normal file
353
Frontend/src/app/components/ui/chart.tsx
Normal 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,
|
||||
};
|
||||
32
Frontend/src/app/components/ui/checkbox.tsx
Normal file
32
Frontend/src/app/components/ui/checkbox.tsx
Normal 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 };
|
||||
33
Frontend/src/app/components/ui/collapsible.tsx
Normal file
33
Frontend/src/app/components/ui/collapsible.tsx
Normal 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 };
|
||||
177
Frontend/src/app/components/ui/command.tsx
Normal file
177
Frontend/src/app/components/ui/command.tsx
Normal 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,
|
||||
};
|
||||
252
Frontend/src/app/components/ui/context-menu.tsx
Normal file
252
Frontend/src/app/components/ui/context-menu.tsx
Normal 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,
|
||||
};
|
||||
135
Frontend/src/app/components/ui/dialog.tsx
Normal file
135
Frontend/src/app/components/ui/dialog.tsx
Normal 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,
|
||||
};
|
||||
132
Frontend/src/app/components/ui/drawer.tsx
Normal file
132
Frontend/src/app/components/ui/drawer.tsx
Normal 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,
|
||||
};
|
||||
257
Frontend/src/app/components/ui/dropdown-menu.tsx
Normal file
257
Frontend/src/app/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
};
|
||||
168
Frontend/src/app/components/ui/form.tsx
Normal file
168
Frontend/src/app/components/ui/form.tsx
Normal 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,
|
||||
};
|
||||
44
Frontend/src/app/components/ui/hover-card.tsx
Normal file
44
Frontend/src/app/components/ui/hover-card.tsx
Normal 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 };
|
||||
77
Frontend/src/app/components/ui/input-otp.tsx
Normal file
77
Frontend/src/app/components/ui/input-otp.tsx
Normal 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 };
|
||||
21
Frontend/src/app/components/ui/input.tsx
Normal file
21
Frontend/src/app/components/ui/input.tsx
Normal 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 };
|
||||
24
Frontend/src/app/components/ui/label.tsx
Normal file
24
Frontend/src/app/components/ui/label.tsx
Normal 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 };
|
||||
276
Frontend/src/app/components/ui/menubar.tsx
Normal file
276
Frontend/src/app/components/ui/menubar.tsx
Normal 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,
|
||||
};
|
||||
168
Frontend/src/app/components/ui/navigation-menu.tsx
Normal file
168
Frontend/src/app/components/ui/navigation-menu.tsx
Normal 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,
|
||||
};
|
||||
127
Frontend/src/app/components/ui/pagination.tsx
Normal file
127
Frontend/src/app/components/ui/pagination.tsx
Normal 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,
|
||||
};
|
||||
48
Frontend/src/app/components/ui/popover.tsx
Normal file
48
Frontend/src/app/components/ui/popover.tsx
Normal 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 };
|
||||
31
Frontend/src/app/components/ui/progress.tsx
Normal file
31
Frontend/src/app/components/ui/progress.tsx
Normal 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 };
|
||||
45
Frontend/src/app/components/ui/radio-group.tsx
Normal file
45
Frontend/src/app/components/ui/radio-group.tsx
Normal 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 };
|
||||
56
Frontend/src/app/components/ui/resizable.tsx
Normal file
56
Frontend/src/app/components/ui/resizable.tsx
Normal 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 };
|
||||
58
Frontend/src/app/components/ui/scroll-area.tsx
Normal file
58
Frontend/src/app/components/ui/scroll-area.tsx
Normal 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 };
|
||||
189
Frontend/src/app/components/ui/select.tsx
Normal file
189
Frontend/src/app/components/ui/select.tsx
Normal 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,
|
||||
};
|
||||
28
Frontend/src/app/components/ui/separator.tsx
Normal file
28
Frontend/src/app/components/ui/separator.tsx
Normal 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 };
|
||||
139
Frontend/src/app/components/ui/sheet.tsx
Normal file
139
Frontend/src/app/components/ui/sheet.tsx
Normal 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,
|
||||
};
|
||||
726
Frontend/src/app/components/ui/sidebar.tsx
Normal file
726
Frontend/src/app/components/ui/sidebar.tsx
Normal 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,
|
||||
};
|
||||
13
Frontend/src/app/components/ui/skeleton.tsx
Normal file
13
Frontend/src/app/components/ui/skeleton.tsx
Normal 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 };
|
||||
63
Frontend/src/app/components/ui/slider.tsx
Normal file
63
Frontend/src/app/components/ui/slider.tsx
Normal 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 };
|
||||
25
Frontend/src/app/components/ui/sonner.tsx
Normal file
25
Frontend/src/app/components/ui/sonner.tsx
Normal 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 };
|
||||
31
Frontend/src/app/components/ui/switch.tsx
Normal file
31
Frontend/src/app/components/ui/switch.tsx
Normal 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 };
|
||||
116
Frontend/src/app/components/ui/table.tsx
Normal file
116
Frontend/src/app/components/ui/table.tsx
Normal 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,
|
||||
};
|
||||
66
Frontend/src/app/components/ui/tabs.tsx
Normal file
66
Frontend/src/app/components/ui/tabs.tsx
Normal 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 };
|
||||
18
Frontend/src/app/components/ui/textarea.tsx
Normal file
18
Frontend/src/app/components/ui/textarea.tsx
Normal 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 };
|
||||
73
Frontend/src/app/components/ui/toggle-group.tsx
Normal file
73
Frontend/src/app/components/ui/toggle-group.tsx
Normal 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 };
|
||||
47
Frontend/src/app/components/ui/toggle.tsx
Normal file
47
Frontend/src/app/components/ui/toggle.tsx
Normal 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 };
|
||||
61
Frontend/src/app/components/ui/tooltip.tsx
Normal file
61
Frontend/src/app/components/ui/tooltip.tsx
Normal 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 };
|
||||
21
Frontend/src/app/components/ui/use-mobile.ts
Normal file
21
Frontend/src/app/components/ui/use-mobile.ts
Normal 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;
|
||||
}
|
||||
6
Frontend/src/app/components/ui/utils.ts
Normal file
6
Frontend/src/app/components/ui/utils.ts
Normal 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));
|
||||
}
|
||||
80
Frontend/src/app/consent.ts
Normal file
80
Frontend/src/app/consent.ts
Normal 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
|
||||
}
|
||||
61
Frontend/src/app/home-nav.ts
Normal file
61
Frontend/src/app/home-nav.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
159
Frontend/src/app/keycloak-theme-form.ts
Normal file
159
Frontend/src/app/keycloak-theme-form.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
82
Frontend/src/app/keycloak-theme-preview.ts
Normal file
82
Frontend/src/app/keycloak-theme-preview.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
216
Frontend/src/app/layouts/AdminLayout.tsx
Normal file
216
Frontend/src/app/layouts/AdminLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
112
Frontend/src/app/layouts/LearnerLayout.tsx
Normal file
112
Frontend/src/app/layouts/LearnerLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
79
Frontend/src/app/learner-certificates.ts
Normal file
79
Frontend/src/app/learner-certificates.ts
Normal 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))
|
||||
}
|
||||
120
Frontend/src/app/learner-communities.ts
Normal file
120
Frontend/src/app/learner-communities.ts
Normal 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
|
||||
}
|
||||
149
Frontend/src/app/learner-courses.ts
Normal file
149
Frontend/src/app/learner-courses.ts
Normal 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: [],
|
||||
}
|
||||
}
|
||||
150
Frontend/src/app/learner-live-courses.ts
Normal file
150
Frontend/src/app/learner-live-courses.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
33
Frontend/src/app/learner-portal.ts
Normal file
33
Frontend/src/app/learner-portal.ts
Normal 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}/`)
|
||||
}
|
||||
136
Frontend/src/app/learner-preferences.ts
Normal file
136
Frontend/src/app/learner-preferences.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
117
Frontend/src/app/learner-scorm.ts
Normal file
117
Frontend/src/app/learner-scorm.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
47
Frontend/src/app/live-course-seo.ts
Normal file
47
Frontend/src/app/live-course-seo.ts
Normal 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)
|
||||
}
|
||||
103
Frontend/src/app/live-courses.ts
Normal file
103
Frontend/src/app/live-courses.ts
Normal 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))
|
||||
}
|
||||
115
Frontend/src/app/mcp/EventBus.ts
Normal file
115
Frontend/src/app/mcp/EventBus.ts
Normal 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()
|
||||
260
Frontend/src/app/mcp/WebhookStore.ts
Normal file
260
Frontend/src/app/mcp/WebhookStore.ts
Normal 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()
|
||||
}
|
||||
27
Frontend/src/app/mcp/bootstrap.ts
Normal file
27
Frontend/src/app/mcp/bootstrap.ts
Normal 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
|
||||
}
|
||||
2
Frontend/src/app/mcp/mcp-types.ts
Normal file
2
Frontend/src/app/mcp/mcp-types.ts
Normal 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'
|
||||
511
Frontend/src/app/mcp/tools.ts
Normal file
511
Frontend/src/app/mcp/tools.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
119
Frontend/src/app/page-renderer.tsx
Normal file
119
Frontend/src/app/page-renderer.tsx
Normal 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('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
}
|
||||
|
||||
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>
|
||||
`
|
||||
}
|
||||
104
Frontend/src/app/page-seo.ts
Normal file
104
Frontend/src/app/page-seo.ts
Normal 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
|
||||
}
|
||||
335
Frontend/src/app/pages/AdminAssetsPage.tsx
Normal file
335
Frontend/src/app/pages/AdminAssetsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
312
Frontend/src/app/pages/AdminAuditPage.tsx
Normal file
312
Frontend/src/app/pages/AdminAuditPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
420
Frontend/src/app/pages/AdminBlogPage.tsx
Normal file
420
Frontend/src/app/pages/AdminBlogPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
233
Frontend/src/app/pages/AdminContentPage.tsx
Normal file
233
Frontend/src/app/pages/AdminContentPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
110
Frontend/src/app/pages/AdminDashboardPage.tsx
Normal file
110
Frontend/src/app/pages/AdminDashboardPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
339
Frontend/src/app/pages/AdminLearnersPage.tsx
Normal file
339
Frontend/src/app/pages/AdminLearnersPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
866
Frontend/src/app/pages/AdminPage.tsx
Normal file
866
Frontend/src/app/pages/AdminPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
300
Frontend/src/app/pages/AdminSettingsPage.tsx
Normal file
300
Frontend/src/app/pages/AdminSettingsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
220
Frontend/src/app/pages/AuthoringRunReviewPage.tsx
Normal file
220
Frontend/src/app/pages/AuthoringRunReviewPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
188
Frontend/src/app/pages/BlogIndexPage.tsx
Normal file
188
Frontend/src/app/pages/BlogIndexPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
166
Frontend/src/app/pages/BlogPostPage.tsx
Normal file
166
Frontend/src/app/pages/BlogPostPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
687
Frontend/src/app/pages/CmsBuilderPage.tsx
Normal file
687
Frontend/src/app/pages/CmsBuilderPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
14
Frontend/src/app/pages/FeatureGuardPage.tsx
Normal file
14
Frontend/src/app/pages/FeatureGuardPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
530
Frontend/src/app/pages/FormsBuilderPage.tsx
Normal file
530
Frontend/src/app/pages/FormsBuilderPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
277
Frontend/src/app/pages/HomePage.tsx
Normal file
277
Frontend/src/app/pages/HomePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
461
Frontend/src/app/pages/KeycloakThemeEditorPage.tsx
Normal file
461
Frontend/src/app/pages/KeycloakThemeEditorPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
175
Frontend/src/app/pages/LearnerCertificatesPage.tsx
Normal file
175
Frontend/src/app/pages/LearnerCertificatesPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
125
Frontend/src/app/pages/LearnerCommunityPage.tsx
Normal file
125
Frontend/src/app/pages/LearnerCommunityPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
124
Frontend/src/app/pages/LearnerCourseDetailPage.tsx
Normal file
124
Frontend/src/app/pages/LearnerCourseDetailPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
160
Frontend/src/app/pages/LearnerDashboardPage.tsx
Normal file
160
Frontend/src/app/pages/LearnerDashboardPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
91
Frontend/src/app/pages/LearnerLiveCoursesPage.tsx
Normal file
91
Frontend/src/app/pages/LearnerLiveCoursesPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
154
Frontend/src/app/pages/LearnerLiveSessionPage.tsx
Normal file
154
Frontend/src/app/pages/LearnerLiveSessionPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
135
Frontend/src/app/pages/LearnerLiveSessionsPage.tsx
Normal file
135
Frontend/src/app/pages/LearnerLiveSessionsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
128
Frontend/src/app/pages/LearnerNotificationsPage.tsx
Normal file
128
Frontend/src/app/pages/LearnerNotificationsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
209
Frontend/src/app/pages/LearnerProfilePage.tsx
Normal file
209
Frontend/src/app/pages/LearnerProfilePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
124
Frontend/src/app/pages/LearnerScormCoursesPage.tsx
Normal file
124
Frontend/src/app/pages/LearnerScormCoursesPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
161
Frontend/src/app/pages/LearnerScormLaunchPage.tsx
Normal file
161
Frontend/src/app/pages/LearnerScormLaunchPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
159
Frontend/src/app/pages/LiveCourseDetailPage.tsx
Normal file
159
Frontend/src/app/pages/LiveCourseDetailPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
100
Frontend/src/app/pages/LiveCoursesIndexPage.tsx
Normal file
100
Frontend/src/app/pages/LiveCoursesIndexPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
133
Frontend/src/app/pages/PlatformAdminPage.tsx
Normal file
133
Frontend/src/app/pages/PlatformAdminPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
46
Frontend/src/app/pages/PublicCmsPage.tsx
Normal file
46
Frontend/src/app/pages/PublicCmsPage.tsx
Normal 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} />
|
||||
}
|
||||
187
Frontend/src/app/pages/PublicFormPage.tsx
Normal file
187
Frontend/src/app/pages/PublicFormPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1576
Frontend/src/app/pages/ScormPage.tsx
Normal file
1576
Frontend/src/app/pages/ScormPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
404
Frontend/src/app/pages/SitemapMenuBuilderPage.tsx
Normal file
404
Frontend/src/app/pages/SitemapMenuBuilderPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1019
Frontend/src/app/pages/scorm-shared.tsx
Normal file
1019
Frontend/src/app/pages/scorm-shared.tsx
Normal file
File diff suppressed because it is too large
Load Diff
132
Frontend/src/app/public-forms.ts
Normal file
132
Frontend/src/app/public-forms.ts
Normal 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')
|
||||
}
|
||||
55
Frontend/src/app/public-pages.ts
Normal file
55
Frontend/src/app/public-pages.ts
Normal 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
|
||||
}
|
||||
64
Frontend/src/app/routes/admin-routes.tsx
Normal file
64
Frontend/src/app/routes/admin-routes.tsx
Normal 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 },
|
||||
],
|
||||
},
|
||||
]
|
||||
17
Frontend/src/app/routes/index.tsx
Normal file
17
Frontend/src/app/routes/index.tsx
Normal 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)
|
||||
Loading…
x
Reference in New Issue
Block a user