diff --git a/Frontend/src/app/routes/learner-routes.tsx b/Frontend/src/app/routes/learner-routes.tsx new file mode 100644 index 0000000..eb4aeee --- /dev/null +++ b/Frontend/src/app/routes/learner-routes.tsx @@ -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 +} + +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 }, + ], + }, +] diff --git a/Frontend/src/app/routes/legacy-routes.tsx b/Frontend/src/app/routes/legacy-routes.tsx new file mode 100644 index 0000000..4bd3d5e --- /dev/null +++ b/Frontend/src/app/routes/legacy-routes.tsx @@ -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 }, +] diff --git a/Frontend/src/app/routes/public-routes.tsx b/Frontend/src/app/routes/public-routes.tsx new file mode 100644 index 0000000..9f33d2e --- /dev/null +++ b/Frontend/src/app/routes/public-routes.tsx @@ -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 + if (roles.includes('admin') || roles.includes('instructor')) return + if (roles.includes('learner')) return + return +} + +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 }, +] diff --git a/Frontend/src/app/sitemap-menu.ts b/Frontend/src/app/sitemap-menu.ts new file mode 100644 index 0000000..69718e3 --- /dev/null +++ b/Frontend/src/app/sitemap-menu.ts @@ -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 + 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 + 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 { + const records = await listResource('sitemap-menus') + return records.map(normalizeSitemapMenu).filter(menu => menu.id && menu.name) +} + +export async function createSitemapMenu(payload: Pick): Promise { + const created = await apiRequest('/api/sitemap-menus/', { + method: 'POST', + body: JSON.stringify(payload), + }) + return normalizeSitemapMenu(created) +} + +export async function updateSitemapMenu(id: string, payload: Pick): Promise { + const updated = await apiRequest(`/api/sitemap-menus/${encodeURIComponent(id)}`, { + method: 'PATCH', + body: JSON.stringify(payload), + }) + return normalizeSitemapMenu(updated) +} + +export async function removeSitemapMenu(id: string): Promise { + await apiRequest(`/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 { + const [pagesRaw, postsRaw, coursesRaw] = await Promise.all([ + listResource('pages'), + apiRequest('/api/blog/posts'), + listResource('live-courses'), + ]) + + const pages = pagesRaw + .map(raw => { + const source = (raw ?? {}) as Record + 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 + 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 + 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('/')) +} diff --git a/Frontend/src/app/types.ts b/Frontend/src/app/types.ts new file mode 100644 index 0000000..4a8791d --- /dev/null +++ b/Frontend/src/app/types.ts @@ -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 + styles: Record + 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[] + loading: boolean + error: string | null +} diff --git a/Frontend/src/main.tsx b/Frontend/src/main.tsx new file mode 100644 index 0000000..a5374e3 --- /dev/null +++ b/Frontend/src/main.tsx @@ -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() + } catch (error) { + const root = document.getElementById('root') + if (root) { + root.innerHTML = '
Failed to initialize authentication. Please refresh and try again.
' + } + console.error('keycloak init failed', error) + } +} + +void bootstrap() diff --git a/Frontend/src/styles/fonts.css b/Frontend/src/styles/fonts.css new file mode 100644 index 0000000..e69de29 diff --git a/Frontend/src/styles/globals.css b/Frontend/src/styles/globals.css new file mode 100644 index 0000000..e69de29 diff --git a/Frontend/src/styles/index.css b/Frontend/src/styles/index.css new file mode 100644 index 0000000..01e7b25 --- /dev/null +++ b/Frontend/src/styles/index.css @@ -0,0 +1,3 @@ +@import './fonts.css'; +@import './tailwind.css'; +@import './theme.css'; diff --git a/Frontend/src/styles/tailwind.css b/Frontend/src/styles/tailwind.css new file mode 100644 index 0000000..e2a61bb --- /dev/null +++ b/Frontend/src/styles/tailwind.css @@ -0,0 +1,4 @@ +@import 'tailwindcss' source(none); +@source '../**/*.{js,ts,jsx,tsx}'; + +@import 'tw-animate-css'; diff --git a/Frontend/src/styles/theme.css b/Frontend/src/styles/theme.css new file mode 100644 index 0000000..a385b63 --- /dev/null +++ b/Frontend/src/styles/theme.css @@ -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; + } +} diff --git a/Frontend/vite.config.ts b/Frontend/vite.config.ts new file mode 100644 index 0000000..57126a6 --- /dev/null +++ b/Frontend/vite.config.ts @@ -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/**'], + }, +})