mirror: import production source 3/3

This commit is contained in:
Admin 2026-09-27 16:59:35 +00:00
parent 17e3db9970
commit 2fdd51f921
12 changed files with 731 additions and 0 deletions

View File

@ -0,0 +1,40 @@
import type { RouteObject } from 'react-router'
import { LearnerLayout } from '../layouts/LearnerLayout'
import { LearnerDashboardPage } from '../pages/LearnerDashboardPage'
import { LearnerLiveCoursesPage } from '../pages/LearnerLiveCoursesPage'
import { LearnerCourseDetailPage } from '../pages/LearnerCourseDetailPage'
import { LearnerScormCoursesPage } from '../pages/LearnerScormCoursesPage'
import { LearnerScormLaunchPage } from '../pages/LearnerScormLaunchPage'
import { LearnerLiveSessionsPage } from '../pages/LearnerLiveSessionsPage'
import { LearnerLiveSessionPage } from '../pages/LearnerLiveSessionPage'
import { LearnerCertificatesPage } from '../pages/LearnerCertificatesPage'
import { LearnerCommunityPage } from '../pages/LearnerCommunityPage'
import { FeatureGuardPage } from '../pages/FeatureGuardPage'
import { LearnerProfilePage } from '../pages/LearnerProfilePage'
import { LearnerNotificationsPage } from '../pages/LearnerNotificationsPage'
function LearnerBillingNotEnabledPage() {
return <FeatureGuardPage title="Learner Billing" summary="Self-service billing is not available in the learner portal yet." />
}
export const learnerRoutes: RouteObject[] = [
{
path: '/learn',
Component: LearnerLayout,
children: [
{ index: true, Component: LearnerDashboardPage },
{ path: 'courses', Component: LearnerLiveCoursesPage },
{ path: 'courses/:slug', Component: LearnerCourseDetailPage },
{ path: 'scorm', Component: LearnerScormCoursesPage },
{ path: 'scorm/:id', Component: LearnerScormLaunchPage },
{ path: 'live', Component: LearnerLiveSessionsPage },
{ path: 'live/:id/sessions/:sessionId', Component: LearnerLiveSessionPage },
{ path: 'certificates', Component: LearnerCertificatesPage },
{ path: 'community', Component: LearnerCommunityPage },
{ path: 'billing', Component: LearnerBillingNotEnabledPage },
{ path: 'profile', Component: LearnerProfilePage },
{ path: 'notifications', Component: LearnerNotificationsPage },
],
},
]

View File

@ -0,0 +1,9 @@
import type { RouteObject } from 'react-router'
import { LearnerLiveCoursesPage } from '../pages/LearnerLiveCoursesPage'
import { LearnerLiveSessionPage } from '../pages/LearnerLiveSessionPage'
export const legacyRoutes: RouteObject[] = [
{ path: '/learner/live-courses', Component: LearnerLiveCoursesPage },
{ path: '/learner/live-courses/:id/sessions/:sessionId', Component: LearnerLiveSessionPage },
]

View File

@ -0,0 +1,37 @@
import { Navigate, type RouteObject } from 'react-router'
import { currentUser } from '../auth/keycloak'
import { BlogIndexPage } from '../pages/BlogIndexPage'
import { BlogPostPage } from '../pages/BlogPostPage'
import { FormsBuilderPage } from '../pages/FormsBuilderPage'
import { HomePage } from '../pages/HomePage'
import { LiveCourseDetailPage } from '../pages/LiveCourseDetailPage'
import { LiveCoursesIndexPage } from '../pages/LiveCoursesIndexPage'
import { PublicFormPage } from '../pages/PublicFormPage'
import { ScormPage } from '../pages/ScormPage'
import { PlatformAdminPage } from '../pages/PlatformAdminPage'
import { PublicCmsPage } from '../pages/PublicCmsPage'
function RootRoleRedirect() {
const roles = (currentUser().roles ?? []).map(role => role.trim().toLowerCase())
if (roles.includes('platform_admin') || roles.includes('platform_owner')) return <Navigate to="/platform-admin" replace />
if (roles.includes('admin') || roles.includes('instructor')) return <Navigate to="/admin" replace />
if (roles.includes('learner')) return <Navigate to="/learn" replace />
return <Navigate to="/home" replace />
}
export const publicRoutes: RouteObject[] = [
{ path: '/', Component: RootRoleRedirect },
{ path: '/home', Component: HomePage },
{ path: '/forms', Component: FormsBuilderPage },
{ path: '/forms/:id', Component: PublicFormPage },
{ path: '/pages/:slug', Component: PublicCmsPage },
{ path: '/scorm', Component: ScormPage },
{ path: '/blog', Component: BlogIndexPage },
{ path: '/blog/category/:categorySlug', Component: BlogIndexPage },
{ path: '/blog/tag/:tagSlug', Component: BlogIndexPage },
{ path: '/blog/:slug', Component: BlogPostPage },
{ path: '/live-courses', Component: LiveCoursesIndexPage },
{ path: '/live-courses/:slug', Component: LiveCourseDetailPage },
{ path: '/platform-admin', Component: PlatformAdminPage },
]

View File

@ -0,0 +1,222 @@
import { apiRequest, listResource } from './api/client'
export type SitemapTargetType = 'page' | 'post' | 'course'
export type SitemapMenuItem = {
id: string
label: string
targetType: SitemapTargetType
targetId: string
to: string
children: SitemapMenuItem[]
}
export type SitemapMenu = {
id: string
name: string
description?: string
items: SitemapMenuItem[]
createdAt?: number
updatedAt?: number
}
export type SitemapSourceOption = {
id: string
label: string
slug?: string
}
export type SitemapSources = {
pages: SitemapSourceOption[]
posts: SitemapSourceOption[]
courses: SitemapSourceOption[]
}
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 normalizeItem(raw: unknown): SitemapMenuItem {
const source = (raw ?? {}) as Record<string, unknown>
const children = Array.isArray(source.children) ? source.children.map(normalizeItem) : []
const targetTypeRaw = asString(source.targetType)
const targetType: SitemapTargetType = targetTypeRaw === 'post' || targetTypeRaw === 'course' ? targetTypeRaw : 'page'
return {
id: asString(source.id),
label: asString(source.label),
targetType,
targetId: asString(source.targetId),
to: asString(source.to),
children,
}
}
export function normalizeSitemapMenu(raw: unknown): SitemapMenu {
const source = (raw ?? {}) as Record<string, unknown>
const items = Array.isArray(source.items) ? source.items.map(normalizeItem) : []
return {
id: asString(source.id),
name: asString(source.name),
description: asString(source.description) || undefined,
items,
createdAt: asNumber(source.createdAt),
updatedAt: asNumber(source.updatedAt),
}
}
export async function fetchSitemapMenus(): Promise<SitemapMenu[]> {
const records = await listResource<unknown>('sitemap-menus')
return records.map(normalizeSitemapMenu).filter(menu => menu.id && menu.name)
}
export async function createSitemapMenu(payload: Pick<SitemapMenu, 'name' | 'description' | 'items'>): Promise<SitemapMenu> {
const created = await apiRequest<unknown>('/api/sitemap-menus/', {
method: 'POST',
body: JSON.stringify(payload),
})
return normalizeSitemapMenu(created)
}
export async function updateSitemapMenu(id: string, payload: Pick<SitemapMenu, 'name' | 'description' | 'items'>): Promise<SitemapMenu> {
const updated = await apiRequest<unknown>(`/api/sitemap-menus/${encodeURIComponent(id)}`, {
method: 'PATCH',
body: JSON.stringify(payload),
})
return normalizeSitemapMenu(updated)
}
export async function removeSitemapMenu(id: string): Promise<void> {
await apiRequest<void>(`/api/sitemap-menus/${encodeURIComponent(id)}`, {
method: 'DELETE',
})
}
export function buildPathForSource(targetType: SitemapTargetType, option: SitemapSourceOption): string {
if (targetType === 'page') {
return '/admin/builder'
}
if (targetType === 'post') {
return option.slug ? `/blog/${option.slug}` : '/blog'
}
return option.slug ? `/live-courses/${option.slug}` : '/live-courses'
}
export async function fetchSitemapSources(): Promise<SitemapSources> {
const [pagesRaw, postsRaw, coursesRaw] = await Promise.all([
listResource<unknown>('pages'),
apiRequest<unknown[]>('/api/blog/posts'),
listResource<unknown>('live-courses'),
])
const pages = pagesRaw
.map(raw => {
const source = (raw ?? {}) as Record<string, unknown>
return {
id: asString(source.id),
label: asString(source.name) || asString(source.slug) || asString(source.id),
slug: asString(source.slug) || undefined,
}
})
.filter(item => item.id && item.label)
const posts = postsRaw
.map(raw => {
const source = (raw ?? {}) as Record<string, unknown>
return {
id: asString(source.id),
label: asString(source.title) || asString(source.slug) || asString(source.id),
slug: asString(source.slug) || undefined,
}
})
.filter(item => item.id && item.label)
const courses = coursesRaw
.map(raw => {
const source = (raw ?? {}) as Record<string, unknown>
return {
id: asString(source.id),
label: asString(source.title) || asString(source.slug) || asString(source.id),
slug: asString(source.slug) || undefined,
}
})
.filter(item => item.id && item.label)
return { pages, posts, courses }
}
export type MenuParentOption = {
id: string
label: string
}
export function flattenParentOptions(items: SitemapMenuItem[], depth = 0): MenuParentOption[] {
const options: MenuParentOption[] = []
for (const item of items) {
options.push({ id: item.id, label: `${' '.repeat(depth * 2)}${item.label}`.trimStart() })
options.push(...flattenParentOptions(item.children, depth + 1))
}
return options
}
export function addMenuItem(items: SitemapMenuItem[], parentId: string | null, newItem: SitemapMenuItem): SitemapMenuItem[] {
if (!parentId) {
return [...items, newItem]
}
return items.map(item => {
if (item.id === parentId) {
return { ...item, children: [...item.children, newItem] }
}
return { ...item, children: addMenuItem(item.children, parentId, newItem) }
})
}
export function removeMenuItem(items: SitemapMenuItem[], itemId: string): SitemapMenuItem[] {
return items
.filter(item => item.id !== itemId)
.map(item => ({ ...item, children: removeMenuItem(item.children, itemId) }))
}
function moveWithinSiblings(items: SitemapMenuItem[], itemId: string, direction: 'up' | 'down'): SitemapMenuItem[] {
const index = items.findIndex(item => item.id === itemId)
if (index === -1) {
return items.map(item => ({ ...item, children: moveWithinSiblings(item.children, itemId, direction) }))
}
const target = direction === 'up' ? index - 1 : index + 1
if (target < 0 || target >= items.length) {
return items
}
const next = [...items]
const [entry] = next.splice(index, 1)
next.splice(target, 0, entry)
return next
}
export function moveMenuItem(items: SitemapMenuItem[], itemId: string, direction: 'up' | 'down'): SitemapMenuItem[] {
return moveWithinSiblings(items, itemId, direction)
}
export function updateMenuItemLabel(items: SitemapMenuItem[], itemId: string, label: string): SitemapMenuItem[] {
return items.map(item => {
if (item.id === itemId) {
return { ...item, label }
}
return { ...item, children: updateMenuItemLabel(item.children, itemId, label) }
})
}
export function toHomeNavItems(items: SitemapMenuItem[]): Array<{ id: string; label: string; to: string }> {
return items
.map(item => ({ id: item.id, label: item.label, to: item.to }))
.filter(item => item.id && item.label && item.to.startsWith('/'))
}

81
Frontend/src/app/types.ts Normal file
View File

@ -0,0 +1,81 @@
export type ThemeConfig = {
background: string
foreground: string
primary: string
primaryForeground: string
muted: string
mutedForeground: string
border: string
accent: string
radius: number
fontFamily: string
}
export type CanvasBlock = {
instanceId: string
blockId: string
content: Record<string, string>
styles: Record<string, string>
slots?: CanvasBlock[][]
}
export type CustomBlockDef = {
id: string
name: string
code: string
}
export type PageOpenGraph = {
title?: string
description?: string
image?: string
url?: string
type?: string
}
export type PageRobots = {
index?: boolean
follow?: boolean
noarchive?: boolean
nosnippet?: boolean
}
export type PageSEO = {
title?: string
description?: string
canonicalUrl?: string
openGraph?: PageOpenGraph
robots?: PageRobots
}
export type Page = {
id: string
name: string
slug?: string
status?: 'draft' | 'published'
publishedAt?: number
seo?: PageSEO
blocks: CanvasBlock[]
theme: ThemeConfig
customBlocks: CustomBlockDef[]
createdAt: number
updatedAt: number
}
export type DataSource = {
id: string
name: string
type: 'rest' | 'static'
url: string
method: 'GET' | 'POST'
headers: string
body: string
staticData: string
path: string
}
export type DataCacheEntry = {
data: Record<string, unknown>[]
loading: boolean
error: string | null
}

19
Frontend/src/main.tsx Normal file
View File

@ -0,0 +1,19 @@
import { createRoot } from 'react-dom/client'
import App from './app/App.tsx'
import './styles/index.css'
import { initKeycloak } from './app/auth/keycloak'
async function bootstrap() {
try {
await initKeycloak()
createRoot(document.getElementById('root')!).render(<App />)
} catch (error) {
const root = document.getElementById('root')
if (root) {
root.innerHTML = '<div style="padding:24px;font-family:system-ui,sans-serif">Failed to initialize authentication. Please refresh and try again.</div>'
}
console.error('keycloak init failed', error)
}
}
void bootstrap()

View File

View File

View File

@ -0,0 +1,3 @@
@import './fonts.css';
@import './tailwind.css';
@import './theme.css';

View File

@ -0,0 +1,4 @@
@import 'tailwindcss' source(none);
@source '../**/*.{js,ts,jsx,tsx}';
@import 'tw-animate-css';

View File

@ -0,0 +1,275 @@
@custom-variant dark (&:is(.dark *));
:root {
--font-size: 16px;
--background: #ffffff;
--foreground: oklch(0.145 0 0);
--card: #ffffff;
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: #030213;
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.95 0.0058 264.53);
--secondary-foreground: #030213;
--muted: #ececf0;
--muted-foreground: #717182;
--accent: #e9ebef;
--accent-foreground: #030213;
--destructive: #d4183d;
--destructive-foreground: #ffffff;
--border: rgba(0, 0, 0, 0.1);
--input: transparent;
--input-background: #f3f3f5;
--switch-background: #cbced4;
--font-weight-medium: 500;
--font-weight-normal: 400;
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: #030213;
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--font-weight-medium: 500;
--font-weight-normal: 400;
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-input-background: var(--input-background);
--color-switch-background: var(--switch-background);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
html {
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--muted-foreground) 55%, transparent) transparent;
}
body::-webkit-scrollbar,
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
body::-webkit-scrollbar-track,
*::-webkit-scrollbar-track {
background: transparent;
}
body::-webkit-scrollbar-thumb,
*::-webkit-scrollbar-thumb {
border-radius: 9999px;
border: 2px solid transparent;
background-clip: padding-box;
background-color: color-mix(in srgb, var(--muted-foreground) 42%, transparent);
}
body::-webkit-scrollbar-thumb:hover,
*::-webkit-scrollbar-thumb:hover {
background-color: color-mix(in srgb, var(--foreground) 52%, transparent);
}
body {
@apply bg-background text-foreground;
}
/**
* Default typography styles for HTML elements (h1-h4, p, label, button, input).
* These are in @layer base, so Tailwind utility classes (like text-sm, text-lg) automatically override them.
*/
html {
font-size: var(--font-size);
}
h1 {
font-size: var(--text-2xl);
font-weight: var(--font-weight-medium);
line-height: 1.5;
}
h2 {
font-size: var(--text-xl);
font-weight: var(--font-weight-medium);
line-height: 1.5;
}
h3 {
font-size: var(--text-lg);
font-weight: var(--font-weight-medium);
line-height: 1.5;
}
h4 {
font-size: var(--text-base);
font-weight: var(--font-weight-medium);
line-height: 1.5;
}
label {
font-size: var(--text-base);
font-weight: var(--font-weight-medium);
line-height: 1.5;
}
button {
font-size: var(--text-base);
font-weight: var(--font-weight-medium);
line-height: 1.5;
}
input {
font-size: var(--text-base);
font-weight: var(--font-weight-normal);
line-height: 1.5;
}
}
@layer base {
:where(a, button, input, select, textarea, [tabindex]):focus-visible {
outline: 2px solid #4f46e5;
outline-offset: 2px;
}
}
@media (max-width: 639px) {
.responsive-table {
display: block;
min-width: 0;
width: 100%;
}
.responsive-table thead {
display: none;
}
.responsive-table tbody {
display: grid;
gap: 0.75rem;
padding: 0.75rem;
}
.responsive-table tr {
display: block;
overflow: hidden;
border: 1px solid rgb(228 228 231);
border-radius: 0.875rem;
background: white;
}
.responsive-table td {
display: grid;
grid-template-columns: minmax(6.5rem, 36%) minmax(0, 1fr);
align-items: start;
gap: 0.75rem;
width: 100%;
border: 0;
padding: 0.55rem 0.75rem;
}
.responsive-table td::before {
content: attr(data-label);
font-size: 0.75rem;
font-weight: 600;
color: rgb(113 113 122);
}
.responsive-table td[data-primary='true'] {
display: block;
border-bottom: 1px solid rgb(244 244 245);
padding-top: 0.8rem;
padding-bottom: 0.8rem;
}
.responsive-table td[data-primary='true']::before {
display: none;
}
.responsive-table td > :where(button, a) {
min-height: 2.5rem;
}
}

41
Frontend/vite.config.ts Normal file
View File

@ -0,0 +1,41 @@
import { defineConfig } from 'vite'
import path from 'path'
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
function figmaAssetResolver() {
return {
name: 'figma-asset-resolver',
resolveId(id) {
if (id.startsWith('figma:asset/')) {
const filename = id.replace('figma:asset/', '')
return path.resolve(__dirname, 'src/assets', filename)
}
},
}
}
export default defineConfig({
plugins: [
figmaAssetResolver(),
// The React and Tailwind plugins are both required for Make, even if
// Tailwind is not being actively used – do not remove them
react(),
tailwindcss(),
],
resolve: {
alias: {
// Alias @ to the src directory
'@': path.resolve(__dirname, './src'),
},
},
// File types to support raw imports. Never add .css, .tsx, or .ts files to this.
assetsInclude: ['**/*.svg', '**/*.csv'],
test: {
environment: 'node',
globals: true,
exclude: ['e2e/**', 'node_modules/**'],
},
})