From 17e3db9970c38eeb03ff60c05a1588593e1a5be4 Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 27 Sep 2026 16:59:12 +0000 Subject: [PATCH] mirror: import production source 2/3 --- Frontend/src/app/components/ui/card.tsx | 92 + Frontend/src/app/components/ui/carousel.tsx | 241 +++ Frontend/src/app/components/ui/chart.tsx | 353 ++++ Frontend/src/app/components/ui/checkbox.tsx | 32 + .../src/app/components/ui/collapsible.tsx | 33 + Frontend/src/app/components/ui/command.tsx | 177 ++ .../src/app/components/ui/context-menu.tsx | 252 +++ Frontend/src/app/components/ui/dialog.tsx | 135 ++ Frontend/src/app/components/ui/drawer.tsx | 132 ++ .../src/app/components/ui/dropdown-menu.tsx | 257 +++ Frontend/src/app/components/ui/form.tsx | 168 ++ Frontend/src/app/components/ui/hover-card.tsx | 44 + Frontend/src/app/components/ui/input-otp.tsx | 77 + Frontend/src/app/components/ui/input.tsx | 21 + Frontend/src/app/components/ui/label.tsx | 24 + Frontend/src/app/components/ui/menubar.tsx | 276 +++ .../src/app/components/ui/navigation-menu.tsx | 168 ++ Frontend/src/app/components/ui/pagination.tsx | 127 ++ Frontend/src/app/components/ui/popover.tsx | 48 + Frontend/src/app/components/ui/progress.tsx | 31 + .../src/app/components/ui/radio-group.tsx | 45 + Frontend/src/app/components/ui/resizable.tsx | 56 + .../src/app/components/ui/scroll-area.tsx | 58 + Frontend/src/app/components/ui/select.tsx | 189 ++ Frontend/src/app/components/ui/separator.tsx | 28 + Frontend/src/app/components/ui/sheet.tsx | 139 ++ Frontend/src/app/components/ui/sidebar.tsx | 726 ++++++++ Frontend/src/app/components/ui/skeleton.tsx | 13 + Frontend/src/app/components/ui/slider.tsx | 63 + Frontend/src/app/components/ui/sonner.tsx | 25 + Frontend/src/app/components/ui/switch.tsx | 31 + Frontend/src/app/components/ui/table.tsx | 116 ++ Frontend/src/app/components/ui/tabs.tsx | 66 + Frontend/src/app/components/ui/textarea.tsx | 18 + .../src/app/components/ui/toggle-group.tsx | 73 + Frontend/src/app/components/ui/toggle.tsx | 47 + Frontend/src/app/components/ui/tooltip.tsx | 61 + Frontend/src/app/components/ui/use-mobile.ts | 21 + Frontend/src/app/components/ui/utils.ts | 6 + Frontend/src/app/consent.ts | 80 + Frontend/src/app/home-nav.ts | 61 + Frontend/src/app/keycloak-theme-form.ts | 159 ++ Frontend/src/app/keycloak-theme-preview.ts | 82 + Frontend/src/app/layouts/AdminLayout.tsx | 216 +++ Frontend/src/app/layouts/LearnerLayout.tsx | 112 ++ Frontend/src/app/learner-certificates.ts | 79 + Frontend/src/app/learner-communities.ts | 120 ++ Frontend/src/app/learner-courses.ts | 149 ++ Frontend/src/app/learner-live-courses.ts | 150 ++ Frontend/src/app/learner-portal.ts | 33 + Frontend/src/app/learner-preferences.ts | 136 ++ Frontend/src/app/learner-scorm.ts | 117 ++ Frontend/src/app/live-course-seo.ts | 47 + Frontend/src/app/live-courses.ts | 103 ++ Frontend/src/app/mcp/EventBus.ts | 115 ++ Frontend/src/app/mcp/WebhookStore.ts | 260 +++ Frontend/src/app/mcp/bootstrap.ts | 27 + Frontend/src/app/mcp/mcp-types.ts | 2 + Frontend/src/app/mcp/tools.ts | 511 ++++++ Frontend/src/app/page-renderer.tsx | 119 ++ Frontend/src/app/page-seo.ts | 104 ++ Frontend/src/app/pages/AdminAssetsPage.tsx | 335 ++++ Frontend/src/app/pages/AdminAuditPage.tsx | 312 ++++ Frontend/src/app/pages/AdminBlogPage.tsx | 420 +++++ Frontend/src/app/pages/AdminContentPage.tsx | 233 +++ Frontend/src/app/pages/AdminDashboardPage.tsx | 110 ++ Frontend/src/app/pages/AdminLearnersPage.tsx | 339 ++++ Frontend/src/app/pages/AdminPage.tsx | 866 +++++++++ Frontend/src/app/pages/AdminSettingsPage.tsx | 300 ++++ .../src/app/pages/AuthoringRunReviewPage.tsx | 220 +++ Frontend/src/app/pages/BlogIndexPage.tsx | 188 ++ Frontend/src/app/pages/BlogPostPage.tsx | 166 ++ Frontend/src/app/pages/CmsBuilderPage.tsx | 687 +++++++ Frontend/src/app/pages/FeatureGuardPage.tsx | 14 + Frontend/src/app/pages/FormsBuilderPage.tsx | 530 ++++++ Frontend/src/app/pages/HomePage.tsx | 277 +++ .../src/app/pages/KeycloakThemeEditorPage.tsx | 461 +++++ .../src/app/pages/LearnerCertificatesPage.tsx | 175 ++ .../src/app/pages/LearnerCommunityPage.tsx | 125 ++ .../src/app/pages/LearnerCourseDetailPage.tsx | 124 ++ .../src/app/pages/LearnerDashboardPage.tsx | 160 ++ .../src/app/pages/LearnerLiveCoursesPage.tsx | 91 + .../src/app/pages/LearnerLiveSessionPage.tsx | 154 ++ .../src/app/pages/LearnerLiveSessionsPage.tsx | 135 ++ .../app/pages/LearnerNotificationsPage.tsx | 128 ++ Frontend/src/app/pages/LearnerProfilePage.tsx | 209 +++ .../src/app/pages/LearnerScormCoursesPage.tsx | 124 ++ .../src/app/pages/LearnerScormLaunchPage.tsx | 161 ++ .../src/app/pages/LiveCourseDetailPage.tsx | 159 ++ .../src/app/pages/LiveCoursesIndexPage.tsx | 100 ++ Frontend/src/app/pages/PlatformAdminPage.tsx | 133 ++ Frontend/src/app/pages/PublicCmsPage.tsx | 46 + Frontend/src/app/pages/PublicFormPage.tsx | 187 ++ Frontend/src/app/pages/ScormPage.tsx | 1576 +++++++++++++++++ .../src/app/pages/SitemapMenuBuilderPage.tsx | 404 +++++ Frontend/src/app/pages/scorm-shared.tsx | 1019 +++++++++++ Frontend/src/app/public-forms.ts | 132 ++ Frontend/src/app/public-pages.ts | 55 + Frontend/src/app/routes/admin-routes.tsx | 64 + Frontend/src/app/routes/index.tsx | 17 + 100 files changed, 18187 insertions(+) create mode 100644 Frontend/src/app/components/ui/card.tsx create mode 100644 Frontend/src/app/components/ui/carousel.tsx create mode 100644 Frontend/src/app/components/ui/chart.tsx create mode 100644 Frontend/src/app/components/ui/checkbox.tsx create mode 100644 Frontend/src/app/components/ui/collapsible.tsx create mode 100644 Frontend/src/app/components/ui/command.tsx create mode 100644 Frontend/src/app/components/ui/context-menu.tsx create mode 100644 Frontend/src/app/components/ui/dialog.tsx create mode 100644 Frontend/src/app/components/ui/drawer.tsx create mode 100644 Frontend/src/app/components/ui/dropdown-menu.tsx create mode 100644 Frontend/src/app/components/ui/form.tsx create mode 100644 Frontend/src/app/components/ui/hover-card.tsx create mode 100644 Frontend/src/app/components/ui/input-otp.tsx create mode 100644 Frontend/src/app/components/ui/input.tsx create mode 100644 Frontend/src/app/components/ui/label.tsx create mode 100644 Frontend/src/app/components/ui/menubar.tsx create mode 100644 Frontend/src/app/components/ui/navigation-menu.tsx create mode 100644 Frontend/src/app/components/ui/pagination.tsx create mode 100644 Frontend/src/app/components/ui/popover.tsx create mode 100644 Frontend/src/app/components/ui/progress.tsx create mode 100644 Frontend/src/app/components/ui/radio-group.tsx create mode 100644 Frontend/src/app/components/ui/resizable.tsx create mode 100644 Frontend/src/app/components/ui/scroll-area.tsx create mode 100644 Frontend/src/app/components/ui/select.tsx create mode 100644 Frontend/src/app/components/ui/separator.tsx create mode 100644 Frontend/src/app/components/ui/sheet.tsx create mode 100644 Frontend/src/app/components/ui/sidebar.tsx create mode 100644 Frontend/src/app/components/ui/skeleton.tsx create mode 100644 Frontend/src/app/components/ui/slider.tsx create mode 100644 Frontend/src/app/components/ui/sonner.tsx create mode 100644 Frontend/src/app/components/ui/switch.tsx create mode 100644 Frontend/src/app/components/ui/table.tsx create mode 100644 Frontend/src/app/components/ui/tabs.tsx create mode 100644 Frontend/src/app/components/ui/textarea.tsx create mode 100644 Frontend/src/app/components/ui/toggle-group.tsx create mode 100644 Frontend/src/app/components/ui/toggle.tsx create mode 100644 Frontend/src/app/components/ui/tooltip.tsx create mode 100644 Frontend/src/app/components/ui/use-mobile.ts create mode 100644 Frontend/src/app/components/ui/utils.ts create mode 100644 Frontend/src/app/consent.ts create mode 100644 Frontend/src/app/home-nav.ts create mode 100644 Frontend/src/app/keycloak-theme-form.ts create mode 100644 Frontend/src/app/keycloak-theme-preview.ts create mode 100644 Frontend/src/app/layouts/AdminLayout.tsx create mode 100644 Frontend/src/app/layouts/LearnerLayout.tsx create mode 100644 Frontend/src/app/learner-certificates.ts create mode 100644 Frontend/src/app/learner-communities.ts create mode 100644 Frontend/src/app/learner-courses.ts create mode 100644 Frontend/src/app/learner-live-courses.ts create mode 100644 Frontend/src/app/learner-portal.ts create mode 100644 Frontend/src/app/learner-preferences.ts create mode 100644 Frontend/src/app/learner-scorm.ts create mode 100644 Frontend/src/app/live-course-seo.ts create mode 100644 Frontend/src/app/live-courses.ts create mode 100644 Frontend/src/app/mcp/EventBus.ts create mode 100644 Frontend/src/app/mcp/WebhookStore.ts create mode 100644 Frontend/src/app/mcp/bootstrap.ts create mode 100644 Frontend/src/app/mcp/mcp-types.ts create mode 100644 Frontend/src/app/mcp/tools.ts create mode 100644 Frontend/src/app/page-renderer.tsx create mode 100644 Frontend/src/app/page-seo.ts create mode 100644 Frontend/src/app/pages/AdminAssetsPage.tsx create mode 100644 Frontend/src/app/pages/AdminAuditPage.tsx create mode 100644 Frontend/src/app/pages/AdminBlogPage.tsx create mode 100644 Frontend/src/app/pages/AdminContentPage.tsx create mode 100644 Frontend/src/app/pages/AdminDashboardPage.tsx create mode 100644 Frontend/src/app/pages/AdminLearnersPage.tsx create mode 100644 Frontend/src/app/pages/AdminPage.tsx create mode 100644 Frontend/src/app/pages/AdminSettingsPage.tsx create mode 100644 Frontend/src/app/pages/AuthoringRunReviewPage.tsx create mode 100644 Frontend/src/app/pages/BlogIndexPage.tsx create mode 100644 Frontend/src/app/pages/BlogPostPage.tsx create mode 100644 Frontend/src/app/pages/CmsBuilderPage.tsx create mode 100644 Frontend/src/app/pages/FeatureGuardPage.tsx create mode 100644 Frontend/src/app/pages/FormsBuilderPage.tsx create mode 100644 Frontend/src/app/pages/HomePage.tsx create mode 100644 Frontend/src/app/pages/KeycloakThemeEditorPage.tsx create mode 100644 Frontend/src/app/pages/LearnerCertificatesPage.tsx create mode 100644 Frontend/src/app/pages/LearnerCommunityPage.tsx create mode 100644 Frontend/src/app/pages/LearnerCourseDetailPage.tsx create mode 100644 Frontend/src/app/pages/LearnerDashboardPage.tsx create mode 100644 Frontend/src/app/pages/LearnerLiveCoursesPage.tsx create mode 100644 Frontend/src/app/pages/LearnerLiveSessionPage.tsx create mode 100644 Frontend/src/app/pages/LearnerLiveSessionsPage.tsx create mode 100644 Frontend/src/app/pages/LearnerNotificationsPage.tsx create mode 100644 Frontend/src/app/pages/LearnerProfilePage.tsx create mode 100644 Frontend/src/app/pages/LearnerScormCoursesPage.tsx create mode 100644 Frontend/src/app/pages/LearnerScormLaunchPage.tsx create mode 100644 Frontend/src/app/pages/LiveCourseDetailPage.tsx create mode 100644 Frontend/src/app/pages/LiveCoursesIndexPage.tsx create mode 100644 Frontend/src/app/pages/PlatformAdminPage.tsx create mode 100644 Frontend/src/app/pages/PublicCmsPage.tsx create mode 100644 Frontend/src/app/pages/PublicFormPage.tsx create mode 100644 Frontend/src/app/pages/ScormPage.tsx create mode 100644 Frontend/src/app/pages/SitemapMenuBuilderPage.tsx create mode 100644 Frontend/src/app/pages/scorm-shared.tsx create mode 100644 Frontend/src/app/public-forms.ts create mode 100644 Frontend/src/app/public-pages.ts create mode 100644 Frontend/src/app/routes/admin-routes.tsx create mode 100644 Frontend/src/app/routes/index.tsx diff --git a/Frontend/src/app/components/ui/card.tsx b/Frontend/src/app/components/ui/card.tsx new file mode 100644 index 0000000..5f9d58a --- /dev/null +++ b/Frontend/src/app/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react"; + +import { cn } from "./utils"; + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +

+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +

+ ); +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +

+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +}; diff --git a/Frontend/src/app/components/ui/carousel.tsx b/Frontend/src/app/components/ui/carousel.tsx new file mode 100644 index 0000000..bb5ab13 --- /dev/null +++ b/Frontend/src/app/components/ui/carousel.tsx @@ -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; +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[0]; + api: ReturnType[1]; + scrollPrev: () => void; + scrollNext: () => void; + canScrollPrev: boolean; + canScrollNext: boolean; +} & CarouselProps; + +const CarouselContext = React.createContext(null); + +function useCarousel() { + const context = React.useContext(CarouselContext); + + if (!context) { + throw new Error("useCarousel must be used within a "); + } + + 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) => { + 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 ( + +
+ {children} +
+
+ ); +} + +function CarouselContent({ className, ...props }: React.ComponentProps<"div">) { + const { carouselRef, orientation } = useCarousel(); + + return ( +
+
+
+ ); +} + +function CarouselItem({ className, ...props }: React.ComponentProps<"div">) { + const { orientation } = useCarousel(); + + return ( +
+ ); +} + +function CarouselPrevious({ + className, + variant = "outline", + size = "icon", + ...props +}: React.ComponentProps) { + const { orientation, scrollPrev, canScrollPrev } = useCarousel(); + + return ( + + ); +} + +function CarouselNext({ + className, + variant = "outline", + size = "icon", + ...props +}: React.ComponentProps) { + const { orientation, scrollNext, canScrollNext } = useCarousel(); + + return ( + + ); +} + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, +}; diff --git a/Frontend/src/app/components/ui/chart.tsx b/Frontend/src/app/components/ui/chart.tsx new file mode 100644 index 0000000..b49bc36 --- /dev/null +++ b/Frontend/src/app/components/ui/chart.tsx @@ -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 } + ); +}; + +type ChartContextProps = { + config: ChartConfig; +}; + +const ChartContext = React.createContext(null); + +function useChart() { + const context = React.useContext(ChartContext); + + if (!context) { + throw new Error("useChart must be used within a "); + } + + 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 ( + +
+ + + {children} + +
+
+ ); +} + +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 ( + + + +${body} + + +` +} \ No newline at end of file diff --git a/Frontend/src/app/page-seo.ts b/Frontend/src/app/page-seo.ts new file mode 100644 index 0000000..f284faa --- /dev/null +++ b/Frontend/src/app/page-seo.ts @@ -0,0 +1,104 @@ +import type { PageSEO } from './types' + +const DEFAULT_SEO: Required> & Omit = { + 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 +} \ No newline at end of file diff --git a/Frontend/src/app/pages/AdminAssetsPage.tsx b/Frontend/src/app/pages/AdminAssetsPage.tsx new file mode 100644 index 0000000..3f024a6 --- /dev/null +++ b/Frontend/src/app/pages/AdminAssetsPage.tsx @@ -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('loading') + const [error, setError] = useState('') + const [assets, setAssets] = useState([]) + const [statusFilter, setStatusFilter] = useState<'all' | AssetStatus>('all') + const [query, setQuery] = useState('') + const [selectedAssetID, setSelectedAssetID] = useState(null) + const [accessEvents, setAccessEvents] = useState([]) + const [claims, setClaims] = useState([]) + 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 ( +
+

Asset Library

+

Loading protected assets and copyright queue...

+
+ ) + } + + if (state === 'error') { + return ( +
+

Asset Library

+

{error}

+ +
+ ) + } + + return ( +
+
+

Asset Library

+

+ Review asset protection posture, copyright metadata, access events, and enforcement actions. +

+
+ +
+
+ {STATUS_FILTERS.map(filter => ( + + ))} +
+ + +
+ +
+
+ + + + + + + + + + + + + + + {filteredAssets.map(asset => ( + setSelectedAssetID(asset.id)} + className={`cursor-pointer border-t border-zinc-100 ${selectedAsset?.id === asset.id ? 'bg-zinc-50' : 'hover:bg-zinc-50/70'}`} + > + + + + + + + + + + ))} + +
AssetStatusVisibilityOwnerLicenceProtectionRelatedActions
+

{asset.title}

+

{asset.id}

+
+ + {asset.status} + + {asset.visibility}{asset.owner}{asset.licence}{asset.protectionLevel}{asset.relatedResource} +
event.stopPropagation()}> + {asset.status !== 'quarantined' ? ( + + ) : null} + {asset.status !== 'disabled' ? ( + + ) : null} + {asset.status !== 'active' ? ( + + ) : null} +
+
+ + {filteredAssets.length === 0 ? ( +
+ No assets matched this filter. +
+ ) : null} +
+ + +
+
+ ) +} diff --git a/Frontend/src/app/pages/AdminAuditPage.tsx b/Frontend/src/app/pages/AdminAuditPage.tsx new file mode 100644 index 0000000..4fd7069 --- /dev/null +++ b/Frontend/src/app/pages/AdminAuditPage.tsx @@ -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('loading') + const [error, setError] = useState('') + const [events, setEvents] = useState([]) + const [filters, setFilters] = useState(INITIAL_FILTERS) + const [search, setSearch] = useState('') + const [selectedID, setSelectedID] = useState(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 ( +
+

Audit Log

+

Loading platform events...

+
+ ) + } + + if (state === 'error') { + return ( +
+

Audit Log

+

{error}

+ +
+ ) + } + + return ( +
+
+

Audit Log

+

+ Review key platform events with actor, action, resource, result, timestamp, and redacted metadata. +

+
+ +
+
+ + + + + + + + + + + +
+ +
+ + + +
+
+ +
+
+ + + + + + + + + + + + {filteredEvents.map(event => ( + setSelectedID(event.id)} + className={`cursor-pointer border-t border-zinc-100 ${selectedEvent?.id === event.id ? 'bg-zinc-50' : 'hover:bg-zinc-50/70'}`} + > + + + + + + + ))} + +
TimestampActorActionResourceResult
{formatTimestamp(event.timestamp)}{event.actor}{event.action}{event.resource} + + {event.result} + +
+ + {filteredEvents.length === 0 ? ( +
+ No audit events matched these filters. +
+ ) : null} +
+ + +
+
+ ) +} diff --git a/Frontend/src/app/pages/AdminBlogPage.tsx b/Frontend/src/app/pages/AdminBlogPage.tsx new file mode 100644 index 0000000..0f519ef --- /dev/null +++ b/Frontend/src/app/pages/AdminBlogPage.tsx @@ -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([]) + const [categories, setCategories] = useState([]) + const [tags, setTags] = useState([]) + const [authors, setAuthors] = useState([]) + const [selectedID, setSelectedID] = useState(null) + const [editor, setEditor] = useState(EMPTY_EDITOR) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [notice, setNotice] = useState(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 ( +
+
+ + +
+
+

Blog Administration

+
+ {selectedID ? ( + + ) : null} + +
+
+ +
+ {error ?

{error}

: null} + {notice ?

{notice}

: null} + +
+ + + + + + + +